Compare commits
43 Commits
f121ac7168
...
3d248d1197
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d248d1197 | |||
| 826f888e87 | |||
| a3d4427190 | |||
| 65e34eef4b | |||
| 61db867e82 | |||
| ac598ea7b1 | |||
| e5579a4d67 | |||
| dcfbfc97ce | |||
| 9b6d541a6a | |||
| d2b648750f | |||
| 4a0fcd0294 | |||
| 9281020d3a | |||
| eef2ddc844 | |||
| c1a3b30e91 | |||
| 4de8a916af | |||
| d0462a87c8 | |||
| 4404de5908 | |||
| 01751f5886 | |||
| 7ecf309459 | |||
| 13bd245872 | |||
| 75832ced24 | |||
| 02b21a2cfe | |||
| a7db1974c3 | |||
| 2b39f43264 | |||
| 4f433e2a8f | |||
| 30ec88ceaf | |||
| 6434d11384 | |||
| 22d14ef9bc | |||
| 7feb6582c4 | |||
| d91ad13af8 | |||
| 705e85b37c | |||
| 8c818fd5ac | |||
| 3bf5f72b4f | |||
| 4d77d42876 | |||
| 952bdaea72 | |||
| e78d88b2f3 | |||
| 626666e6f0 | |||
| cd7f99f59d | |||
| 45d73759c1 | |||
| e67872cb6a | |||
| 312a77aeb9 | |||
| f146bb0f81 | |||
| 356d22da4c |
@@ -1,82 +0,0 @@
|
|||||||
# Component Builder
|
|
||||||
|
|
||||||
You are the component-builder agent for the FA Design System. Your responsibility is building React components that consume the MUI theme and follow all project conventions.
|
|
||||||
|
|
||||||
## Before starting
|
|
||||||
|
|
||||||
1. Read `docs/memory/session-log.md` — understand what's been done
|
|
||||||
2. Read `docs/memory/decisions-log.md` — don't contradict previous decisions
|
|
||||||
3. Read `docs/memory/component-registry.md` — check status, avoid duplicates
|
|
||||||
4. Read `docs/memory/token-registry.md` — know which tokens are available
|
|
||||||
5. Read `docs/conventions/component-conventions.md` — follow all rules
|
|
||||||
6. Read `docs/design-system.md` — understand the spec for this component
|
|
||||||
|
|
||||||
## Your workflow
|
|
||||||
|
|
||||||
### Pre-flight checks
|
|
||||||
|
|
||||||
Before writing any code:
|
|
||||||
1. **Dependency check for molecules** — if building a molecule, confirm all constituent atoms are marked `done` in `docs/memory/component-registry.md`. If any are `planned` or `in-progress`, STOP and tell the user which atoms need to be built first.
|
|
||||||
2. **Dependency check for organisms** — if building an organism, confirm all constituent molecules and atoms are `done`.
|
|
||||||
3. **Token check** — confirm `docs/memory/token-registry.md` has populated token entries. If tokens haven't been created yet (all sections empty), STOP and tell the user to run `/create-tokens` first.
|
|
||||||
|
|
||||||
### Building a component
|
|
||||||
|
|
||||||
1. **Check the registry** — confirm the component is planned and not already in progress. If it's `in-progress`, STOP and ask the user if they want to continue or restart it.
|
|
||||||
2. **Update the registry** — mark status as `in-progress`
|
|
||||||
3. **Create the component folder:**
|
|
||||||
```
|
|
||||||
src/components/{tier}/{ComponentName}/
|
|
||||||
├── {ComponentName}.tsx
|
|
||||||
├── {ComponentName}.stories.tsx
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
4. **Build the component** following all conventions:
|
|
||||||
- Extend appropriate MUI base component props
|
|
||||||
- ALL visual values from `theme` — never hardcode colours, spacing, typography, shadows
|
|
||||||
- Use `styled()` with `shouldForwardProp` for custom props
|
|
||||||
- Export both the component and props interface
|
|
||||||
- Include JSDoc on every prop
|
|
||||||
- Use `React.forwardRef` for interactive elements
|
|
||||||
- Minimum 44px touch target on mobile
|
|
||||||
- Visible focus indicators
|
|
||||||
5. **Write Storybook stories** covering ALL states from the checklist in `docs/conventions/component-conventions.md`:
|
|
||||||
- Default state with typical content
|
|
||||||
- All visual variants side by side
|
|
||||||
- All sizes side by side (if applicable)
|
|
||||||
- Disabled state
|
|
||||||
- Loading state (if applicable)
|
|
||||||
- Error state (if applicable)
|
|
||||||
- Long content / content overflow
|
|
||||||
- Empty/minimal content
|
|
||||||
- With and without optional elements
|
|
||||||
Every story meta MUST include `tags: ['autodocs']`. Do NOT mark the component done until all applicable stories exist.
|
|
||||||
6. **Create component tokens** in `tokens/component/{component}.json` if the component has stateful visual variants (e.g., `button.background.hover`, `input.border.error`) not covered by semantic tokens. If the component only uses existing semantic tokens, skip this step.
|
|
||||||
7. **Always create the `index.ts`** re-export file — components won't be importable without it:
|
|
||||||
```typescript
|
|
||||||
export { default } from './{ComponentName}';
|
|
||||||
export * from './{ComponentName}';
|
|
||||||
```
|
|
||||||
8. **Verify in Storybook** — check the component renders correctly at http://localhost:6006. If it doesn't render, fix the issue before proceeding.
|
|
||||||
|
|
||||||
### Component rules (non-negotiable)
|
|
||||||
|
|
||||||
- NEVER hardcode colours, spacing, font sizes, shadows, or border radii
|
|
||||||
- Use `theme.palette.*`, `theme.spacing()`, `theme.typography.*`, `theme.shape.*`
|
|
||||||
- Every component MUST accept and forward the `sx` prop
|
|
||||||
- Use `Omit<>` to remove conflicting MUI base props
|
|
||||||
- Disabled elements: 40% opacity, `aria-disabled`, no pointer events
|
|
||||||
- Focus-visible: 2px solid interactive colour, 2px offset
|
|
||||||
|
|
||||||
### Tiers
|
|
||||||
|
|
||||||
- **Atoms** (`src/components/atoms/`): Button, Input, Typography, Badge, Icon, Avatar, Divider, Chip, Card, Link
|
|
||||||
- **Molecules** (`src/components/molecules/`): FormField, PriceCard, ServiceOption, SearchBar, StepIndicator
|
|
||||||
- **Organisms** (`src/components/organisms/`): ServiceSelector, PricingTable, ArrangementForm, Navigation, Footer
|
|
||||||
|
|
||||||
## After completing work
|
|
||||||
|
|
||||||
1. Update `docs/memory/component-registry.md` — mark component status as `review`
|
|
||||||
2. Update `docs/memory/token-registry.md` if you created any component tokens
|
|
||||||
3. Update `docs/memory/decisions-log.md` with any design decisions
|
|
||||||
4. Update `docs/memory/session-log.md` with work summary and next steps
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Story Writer
|
|
||||||
|
|
||||||
You are the story-writer agent for the FA Design System. Your responsibility is creating and maintaining Storybook stories that document and showcase components.
|
|
||||||
|
|
||||||
## Before starting
|
|
||||||
|
|
||||||
1. Read `docs/memory/session-log.md` — understand what's been done
|
|
||||||
2. Read `docs/memory/component-registry.md` — know which components exist and their status
|
|
||||||
3. Read `docs/conventions/component-conventions.md` — follow story conventions
|
|
||||||
4. Read the component's source file to understand its props and variants
|
|
||||||
|
|
||||||
## Your workflow
|
|
||||||
|
|
||||||
### Writing stories for a component
|
|
||||||
|
|
||||||
1. **Read the component** — understand all props, variants, and states
|
|
||||||
2. **Create or update** `{ComponentName}.stories.tsx` in the component folder
|
|
||||||
3. **Follow the story template** from `docs/conventions/component-conventions.md`
|
|
||||||
4. **Cover all required states** (see checklist below)
|
|
||||||
5. **Verify in Storybook** at http://localhost:6006
|
|
||||||
|
|
||||||
### Story structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { ComponentName } from './ComponentName';
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComponentName> = {
|
|
||||||
title: '{Tier}/{ComponentName}', // e.g., 'Atoms/Button'
|
|
||||||
component: ComponentName,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered', // 'centered' for atoms, 'padded' or 'fullscreen' for larger
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// One entry per prop with control type, options, description
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComponentName>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Coverage checklist (every component MUST have)
|
|
||||||
|
|
||||||
- [ ] **Default** — typical usage with standard content
|
|
||||||
- [ ] **AllVariants** — all visual variants side by side (if applicable)
|
|
||||||
- [ ] **AllSizes** — all size options side by side (if applicable)
|
|
||||||
- [ ] **Disabled** — disabled state
|
|
||||||
- [ ] **Loading** — loading state (if applicable)
|
|
||||||
- [ ] **Error** — error state (if applicable)
|
|
||||||
- [ ] **LongContent** — text overflow / long content handling
|
|
||||||
- [ ] **MinimalContent** — empty or minimal content
|
|
||||||
- [ ] **WithOptionalElements** — with/without icons, badges, etc.
|
|
||||||
|
|
||||||
### Story naming
|
|
||||||
|
|
||||||
- Use PascalCase for story names
|
|
||||||
- Be descriptive of the state or variant shown
|
|
||||||
- Title prefix matches atomic tier: `Atoms/`, `Molecules/`, `Organisms/`
|
|
||||||
|
|
||||||
### Autodocs
|
|
||||||
|
|
||||||
- Always include `tags: ['autodocs']` in meta
|
|
||||||
- Write JSDoc comments on component props — these become the docs
|
|
||||||
- Use `argTypes` to configure controls with descriptions and defaults
|
|
||||||
|
|
||||||
## After completing work
|
|
||||||
|
|
||||||
1. Update `docs/memory/session-log.md` noting which stories were written/updated
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Token Architect
|
|
||||||
|
|
||||||
You are the token-architect agent for the FA Design System. Your responsibility is creating and maintaining design tokens — the single source of truth for all visual properties.
|
|
||||||
|
|
||||||
## Before starting
|
|
||||||
|
|
||||||
1. Read `docs/memory/session-log.md` — understand what's been done
|
|
||||||
2. Read `docs/memory/decisions-log.md` — don't contradict previous decisions
|
|
||||||
3. Read `docs/memory/token-registry.md` — know what tokens already exist
|
|
||||||
4. Read `docs/conventions/token-conventions.md` — follow all naming rules
|
|
||||||
5. Read `docs/design-system.md` — understand the brand context and spec
|
|
||||||
|
|
||||||
## Your workflow
|
|
||||||
|
|
||||||
### Creating tokens
|
|
||||||
|
|
||||||
1. **Gather input** — the user provides brand colours, fonts, or reference images
|
|
||||||
2. **Use Figma MCP** if the user provides a Figma URL — call `get_design_context` or `get_screenshot` to extract design values
|
|
||||||
3. **Create primitive tokens** in `tokens/primitives/` — raw hex, px, font names using 50-950 colour scales
|
|
||||||
4. **Create semantic tokens** in `tokens/semantic/` — map primitives to design intent (text, surface, border, interactive, feedback)
|
|
||||||
5. **Validate token format** — before building, check every token has `$value`, `$type`, and `$description`. Missing `$description` is the most common mistake.
|
|
||||||
6. **Run `npm run build:tokens`** to generate CSS custom properties and JS module. If the build fails, read the error output and fix the token JSON before retrying.
|
|
||||||
7. **Update the MUI theme** in `src/theme/index.ts` to consume the generated token values. Common mappings:
|
|
||||||
- `color.brand.primary` → `palette.primary.main`
|
|
||||||
- `color.text.primary` → `palette.text.primary`
|
|
||||||
- `color.surface.default` → `palette.background.default`
|
|
||||||
- `color.feedback.*` → `palette.error.main`, `palette.warning.main`, etc.
|
|
||||||
- `fontFamily.heading` / `fontFamily.body` → `typography.fontFamily`
|
|
||||||
- Import values from `./generated/tokens.js`
|
|
||||||
8. **Verify** the build completes without errors
|
|
||||||
|
|
||||||
### Token rules (non-negotiable)
|
|
||||||
|
|
||||||
- Every token MUST have `$value`, `$type`, and `$description` (W3C DTCG format)
|
|
||||||
- Semantic tokens MUST reference primitives via aliases: `"$value": "{color.blue.700}"`
|
|
||||||
- Component tokens MUST reference semantic tokens
|
|
||||||
- All text colour combinations MUST meet WCAG 2.1 AA contrast (4.5:1 normal, 3:1 large)
|
|
||||||
- Use the `--fa-` CSS custom property prefix
|
|
||||||
|
|
||||||
### File structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tokens/primitives/colours.json — brand, neutral, feedback hue scales
|
|
||||||
tokens/primitives/typography.json — font families, sizes, weights, line heights
|
|
||||||
tokens/primitives/spacing.json — spacing scale, border radius
|
|
||||||
tokens/primitives/effects.json — shadows, opacity
|
|
||||||
tokens/semantic/colours.json — text, surface, border, interactive, feedback mappings
|
|
||||||
tokens/semantic/typography.json — typography role mappings (display, h1, body, etc.)
|
|
||||||
tokens/semantic/spacing.json — layout and component spacing
|
|
||||||
tokens/component/*.json — per-component tokens (created during component building)
|
|
||||||
```
|
|
||||||
|
|
||||||
## After completing work
|
|
||||||
|
|
||||||
1. Update `docs/memory/token-registry.md` with every token you created/modified
|
|
||||||
2. Update `docs/memory/decisions-log.md` with any design decisions and rationale
|
|
||||||
3. Update `docs/memory/session-log.md` with work summary and next steps
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
---
|
|
||||||
name: adapt
|
|
||||||
description: Responsive adaptation review — checks touch targets, overflow, text scaling, content reflow, and mobile spacing across breakpoints.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to check]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Run a responsive design review on a component or area, then **fix issues found**. This is assessment AND fix — diagnose breakpoint problems, touch target failures, and mobile usability issues, then apply corrections.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA responsive conventions and breakpoints
|
|
||||||
2. Read the target component/area source files and stories
|
|
||||||
3. Reference `docs/reference/impeccable/responsive-design.md` for detailed responsive guidelines
|
|
||||||
|
|
||||||
**FA context reminder**: Many Funeral Arranger users are older adults, potentially on older or budget devices, and may be using the platform on mobile during a difficult time. Touch interactions must be generous. Layouts must not break on small screens. Nothing should require precise fine-motor control or hover-only interactions. The platform must work on a 5-year-old Android phone on a slow connection just as well as on a modern desktop.
|
|
||||||
|
|
||||||
## Diagnostic Checks
|
|
||||||
|
|
||||||
Work through each check systematically. For each issue found, note the problem, then fix it.
|
|
||||||
|
|
||||||
### 1. Touch Targets
|
|
||||||
|
|
||||||
- All interactive elements (buttons, links, form controls, toggles) must have a minimum touch target of 44x44px
|
|
||||||
- Check `padding`, `min-height`, and `min-width` on clickable elements — the visual size can be smaller if the tap area is padded out
|
|
||||||
- Text links in body copy need adequate line-height or padding to create 44px+ tap areas
|
|
||||||
- Adjacent touch targets must have sufficient spacing (at least 8px gap) to prevent mis-taps
|
|
||||||
- Icon-only buttons are especially prone to being too small — verify dimensions include padding
|
|
||||||
- This is **critical** for FA's audience — elderly users and users under emotional stress have reduced fine-motor precision
|
|
||||||
|
|
||||||
### 2. Horizontal Overflow
|
|
||||||
|
|
||||||
- No horizontal scrolling on viewports as narrow as 320px (iPhone SE, older Android devices)
|
|
||||||
- Check for fixed widths (`width: 400px`, `min-width: 500px`) that exceed narrow viewports
|
|
||||||
- Check for content that breaks out of containers (long words, URLs, email addresses without `word-break` or `overflow-wrap`)
|
|
||||||
- Tables must adapt on mobile — use responsive patterns (card layout, horizontal scroll with visual indicator, or progressive disclosure)
|
|
||||||
- Images must have `max-width: 100%` or equivalent constraint
|
|
||||||
- Check for `flex-shrink: 0` or `flex: none` on elements that should be allowed to shrink
|
|
||||||
|
|
||||||
### 3. Text Readability at Mobile Sizes
|
|
||||||
|
|
||||||
- Body text minimum 14px on mobile (16px preferred)
|
|
||||||
- Use `rem` units for font sizes so text respects browser zoom/accessibility settings
|
|
||||||
- Never use `user-scalable=no` in viewport meta — this breaks accessibility
|
|
||||||
- Layouts must not break at 200% browser zoom (WCAG requirement)
|
|
||||||
- Small/caption text should remain legible — minimum 12px
|
|
||||||
- Check that heading sizes scale down appropriately on mobile (a 48px desktop heading should not stay 48px on a 320px screen)
|
|
||||||
|
|
||||||
### 4. Content Reflow
|
|
||||||
|
|
||||||
- Content should reflow logically between breakpoints, not just shrink
|
|
||||||
- Multi-column layouts should collapse to single column on mobile
|
|
||||||
- Side-by-side arrangements (image + text, icon + label) should stack vertically when space is constrained
|
|
||||||
- Check `flex-direction` and `grid-template-columns` for responsive adjustments
|
|
||||||
- Navigation should adapt: full horizontal on desktop, hamburger/drawer on mobile
|
|
||||||
- Cards in a grid should reduce column count at narrow widths, not just shrink cards
|
|
||||||
|
|
||||||
### 5. Hover-Only Interactions
|
|
||||||
|
|
||||||
- No functionality should require hover — touch users cannot hover
|
|
||||||
- Tooltips that appear only on hover need a touch alternative (tap to show, or visible by default on mobile)
|
|
||||||
- Hover-revealed actions (edit buttons, delete icons) must have an alternative on touch devices
|
|
||||||
- Use `@media (hover: hover)` for hover enhancements, with fallback for `@media (hover: none)`
|
|
||||||
- Dropdown menus triggered by hover must also work on tap
|
|
||||||
- Check for CSS `:hover` styles that hide/show content without a touch-friendly fallback
|
|
||||||
|
|
||||||
### 6. Spacing Adjustments Per Breakpoint
|
|
||||||
|
|
||||||
- Padding should be tighter on mobile (16px) and more generous on desktop (24-48px)
|
|
||||||
- Section spacing should compress on mobile to keep content scannable without excessive scrolling
|
|
||||||
- Card padding should adapt — desktop cards can be spacious, mobile cards should be compact but not cramped
|
|
||||||
- Check that spacing uses theme.spacing() or responsive values, not fixed values that work only at one breakpoint
|
|
||||||
- Container max-widths should be set appropriately for each breakpoint
|
|
||||||
|
|
||||||
### 7. Image and Asset Sizing
|
|
||||||
|
|
||||||
- Images should have responsive sizing (`max-width: 100%`, `height: auto`)
|
|
||||||
- Consider `loading="lazy"` for images below the fold (important for FA users on slow connections)
|
|
||||||
- Decorative images can be hidden on mobile if they add no informational value
|
|
||||||
- Avatar/icon sizes should be appropriate for mobile — not too large (wasting space) or too small (illegible)
|
|
||||||
- Check for background images that might not display well on small screens
|
|
||||||
|
|
||||||
## Fix Process
|
|
||||||
|
|
||||||
For each issue found:
|
|
||||||
|
|
||||||
1. **Identify** the file, line, and current value
|
|
||||||
2. **Test mentally at 320px, 768px, and 1280px** — does the fix work across all three?
|
|
||||||
3. **Apply the fix** — use responsive utilities (MUI breakpoints, media queries, responsive props)
|
|
||||||
4. **Verify** the fix does not introduce new issues at other breakpoints
|
|
||||||
5. **Prefer CSS/theme solutions** over JavaScript breakpoint detection where possible
|
|
||||||
|
|
||||||
## Report Format
|
|
||||||
|
|
||||||
After fixing, present a summary:
|
|
||||||
|
|
||||||
### Responsive Health Summary
|
|
||||||
|
|
||||||
| Check | Status | Issues Found | Issues Fixed |
|
|
||||||
|-------|--------|-------------|-------------|
|
|
||||||
| Touch Targets (44px min) | pass/warn/fail | N | N |
|
|
||||||
| Horizontal Overflow (320px) | pass/warn/fail | N | N |
|
|
||||||
| Text Readability (mobile) | pass/warn/fail | N | N |
|
|
||||||
| Content Reflow | pass/warn/fail | N | N |
|
|
||||||
| Hover-Only Interactions | pass/warn/fail | N | N |
|
|
||||||
| Breakpoint Spacing | pass/warn/fail | N | N |
|
|
||||||
| Image/Asset Sizing | pass/warn/fail | N | N |
|
|
||||||
|
|
||||||
### Breakpoint Walkthrough
|
|
||||||
|
|
||||||
Brief assessment of the component at each key breakpoint:
|
|
||||||
- **320px** (small phone): Does it fit? Is it usable?
|
|
||||||
- **768px** (tablet): Does the layout adapt sensibly?
|
|
||||||
- **1280px** (desktop): Does it use the available space well?
|
|
||||||
|
|
||||||
### Changes Made
|
|
||||||
|
|
||||||
For each fix applied:
|
|
||||||
- **What changed**: Brief description
|
|
||||||
- **File**: Path and relevant line(s)
|
|
||||||
- **Before/After**: The old and new values
|
|
||||||
- **Breakpoint affected**: Which viewport size(s) this fix targets
|
|
||||||
|
|
||||||
### Remaining Concerns
|
|
||||||
|
|
||||||
Any issues that need design input, Storybook viewport testing, or are outside the scope of a responsive pass.
|
|
||||||
|
|
||||||
**NEVER**: Use fixed pixel widths for containers. Add `user-scalable=no` to viewport meta. Rely on hover for essential functionality. Assume all users have modern large-screen devices. Ignore the 320px viewport — real people use iPhone SE and budget Android phones. Let touch targets fall below 44px.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
name: audit
|
|
||||||
description: Run technical quality checks across accessibility, performance, theming, responsive design, and anti-patterns. Generates a scored report with P0-P3 severity ratings. Adapted from impeccable (Apache 2.0).
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to audit]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Run systematic **technical** quality checks on a component or area and generate a scored report. This is assessment-only — don't fix issues, document them.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design conventions
|
|
||||||
2. Read the component/area source files
|
|
||||||
3. Reference `docs/reference/impeccable/` for detailed design guidelines when scoring
|
|
||||||
4. Reference `docs/reference/vercel/web-design-guidelines.md` for additional WCAG/accessibility rules (optional, for deeper a11y scoring)
|
|
||||||
|
|
||||||
## Diagnostic Scan
|
|
||||||
|
|
||||||
Score each dimension 0-4.
|
|
||||||
|
|
||||||
### 1. Accessibility (A11y)
|
|
||||||
|
|
||||||
**Check for**:
|
|
||||||
- Contrast ratios < 4.5:1 for text, < 3:1 for large text and UI components
|
|
||||||
- Missing ARIA: interactive elements without proper roles, labels, or states
|
|
||||||
- Keyboard navigation: missing focus-visible indicators, illogical tab order
|
|
||||||
- Semantic HTML: divs instead of buttons, missing landmarks, heading hierarchy
|
|
||||||
- Form issues: inputs without labels, poor error messaging
|
|
||||||
- Touch targets < 44px (critical for FA's audience — elderly, distressed)
|
|
||||||
|
|
||||||
**Score**: 0=Fails WCAG A, 1=Major gaps, 2=Partial effort, 3=AA mostly met, 4=AA fully met
|
|
||||||
|
|
||||||
### 2. Performance
|
|
||||||
|
|
||||||
**Check for**:
|
|
||||||
- Expensive animations: animating layout properties instead of transform/opacity
|
|
||||||
- Missing optimisation: unoptimised assets, missing lazy loading
|
|
||||||
- Bundle concerns: unnecessary imports, unused dependencies
|
|
||||||
- Render performance: unnecessary re-renders, missing memoisation
|
|
||||||
|
|
||||||
**Score**: 0=Severe issues, 1=Major problems, 2=Partial, 3=Mostly optimised, 4=Excellent
|
|
||||||
|
|
||||||
### 3. Theming & Token Compliance
|
|
||||||
|
|
||||||
**Check for**:
|
|
||||||
- Hardcoded colours not using theme palette or CSS variables
|
|
||||||
- Hardcoded spacing not using theme.spacing() or token values
|
|
||||||
- Hardcoded typography not using theme.typography variants
|
|
||||||
- Inconsistent token usage: wrong tier (primitive instead of semantic)
|
|
||||||
- Component tokens missing `$description` fields
|
|
||||||
|
|
||||||
**Score**: 0=Hardcoded everything, 1=Minimal tokens, 2=Inconsistent, 3=Good with minor gaps, 4=Full token compliance
|
|
||||||
|
|
||||||
### 4. Responsive Design
|
|
||||||
|
|
||||||
**Check for**:
|
|
||||||
- Fixed widths that break on mobile
|
|
||||||
- Touch targets < 44px on interactive elements
|
|
||||||
- Horizontal scroll/overflow on narrow viewports
|
|
||||||
- Text scaling: layouts that break when text size increases
|
|
||||||
- Missing responsive padding (mobile vs desktop)
|
|
||||||
|
|
||||||
**Score**: 0=Desktop-only, 1=Major issues, 2=Partial, 3=Good, 4=Fluid and responsive
|
|
||||||
|
|
||||||
### 5. Design Quality
|
|
||||||
|
|
||||||
**Check against these anti-patterns** (from impeccable frontend-design guidelines):
|
|
||||||
- Gray text on coloured backgrounds (looks washed out)
|
|
||||||
- Cards nested inside cards (visual noise)
|
|
||||||
- Identical card grids with no variation
|
|
||||||
- Bounce/elastic easing (dated, tacky)
|
|
||||||
- Every button styled as primary (no hierarchy)
|
|
||||||
- Redundant copy (headers restating the same info)
|
|
||||||
- Glassmorphism/blur used decoratively
|
|
||||||
- Missing interactive states (hover without focus, or vice versa)
|
|
||||||
|
|
||||||
**Score**: 0=Multiple anti-patterns, 1=Several issues, 2=A couple, 3=Mostly clean, 4=Intentional, distinctive design
|
|
||||||
|
|
||||||
## Report Format
|
|
||||||
|
|
||||||
### Audit Health Score
|
|
||||||
|
|
||||||
| # | Dimension | Score | Key Finding |
|
|
||||||
|---|-----------|-------|-------------|
|
|
||||||
| 1 | Accessibility | ? | |
|
|
||||||
| 2 | Performance | ? | |
|
|
||||||
| 3 | Theming & Tokens | ? | |
|
|
||||||
| 4 | Responsive Design | ? | |
|
|
||||||
| 5 | Design Quality | ? | |
|
|
||||||
| **Total** | | **??/20** | **[Rating]** |
|
|
||||||
|
|
||||||
**Ratings**: 18-20 Excellent, 14-17 Good, 10-13 Acceptable, 6-9 Poor, 0-5 Critical
|
|
||||||
|
|
||||||
### Executive Summary
|
|
||||||
- Score and rating
|
|
||||||
- Issue count by severity (P0/P1/P2/P3)
|
|
||||||
- Top 3-5 critical issues
|
|
||||||
- Recommended next steps
|
|
||||||
|
|
||||||
### Detailed Findings
|
|
||||||
|
|
||||||
Tag each issue **P0-P3**:
|
|
||||||
- **P0 Blocking**: Prevents task completion — fix immediately
|
|
||||||
- **P1 Major**: WCAG AA violation or significant UX issue — fix before release
|
|
||||||
- **P2 Minor**: Annoyance, workaround exists — fix in next pass
|
|
||||||
- **P3 Polish**: Nice-to-fix — address if time permits
|
|
||||||
|
|
||||||
For each issue:
|
|
||||||
- **[P?] Issue name**
|
|
||||||
- **Location**: Component, file, line
|
|
||||||
- **Category**: Accessibility / Performance / Theming / Responsive / Design Quality
|
|
||||||
- **Impact**: How it affects users
|
|
||||||
- **Recommendation**: How to fix it
|
|
||||||
|
|
||||||
### Positive Findings
|
|
||||||
Note what's working well — good practices to maintain.
|
|
||||||
|
|
||||||
**NEVER**: Report issues without explaining impact. Provide generic recommendations. Skip positive findings. Mark everything as P0.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
name: build-atom
|
|
||||||
description: Build an atom component (Button, Input, Typography, etc.)
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Build an atom component for the FA Design System.
|
|
||||||
|
|
||||||
Use the component-builder agent to handle this task. The user wants to build the following atom component:
|
|
||||||
|
|
||||||
**Component:** $ARGUMENTS
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read all memory files and conventions before starting
|
|
||||||
2. Check `docs/memory/component-registry.md` to confirm the component is planned
|
|
||||||
3. Create the component in `src/components/atoms/{ComponentName}/`
|
|
||||||
4. Include: `{ComponentName}.tsx`, `{ComponentName}.stories.tsx`, `index.ts`
|
|
||||||
5. Follow all rules in `docs/conventions/component-conventions.md`
|
|
||||||
6. ALL visual values MUST come from the MUI theme — never hardcode
|
|
||||||
7. Write comprehensive Storybook stories covering all states
|
|
||||||
8. Verify the component renders in Storybook
|
|
||||||
9. **Run internal QA** (stages 3-5 from `docs/reference/component-lifecycle.md`):
|
|
||||||
- Run `/audit` — fix all P0/P1 issues
|
|
||||||
- Run `/critique` (skip for simple utility atoms like Divider)
|
|
||||||
- Run `/harden` (skip for display-only atoms like Typography, Badge)
|
|
||||||
- Run `/polish` — final alignment, spacing, transitions
|
|
||||||
10. Present to user with summary and scores
|
|
||||||
11. Update all memory files when done
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
name: build-molecule
|
|
||||||
description: Build a molecule component (PriceCard, FormField, etc.)
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Build a molecule component for the FA Design System.
|
|
||||||
|
|
||||||
Use the component-builder agent to handle this task. The user wants to build the following molecule component:
|
|
||||||
|
|
||||||
**Component:** $ARGUMENTS
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read all memory files and conventions before starting
|
|
||||||
2. Check `docs/memory/component-registry.md` to confirm the component is planned and that its constituent atoms are `done`
|
|
||||||
3. Create the component in `src/components/molecules/{ComponentName}/`
|
|
||||||
4. Include: `{ComponentName}.tsx`, `{ComponentName}.stories.tsx`, `index.ts`
|
|
||||||
5. Compose from existing atom components — import from `@atoms/`
|
|
||||||
6. Follow all rules in `docs/conventions/component-conventions.md`
|
|
||||||
7. ALL visual values MUST come from the MUI theme — never hardcode
|
|
||||||
8. Write comprehensive Storybook stories with realistic content
|
|
||||||
9. Verify the component renders in Storybook
|
|
||||||
10. **Run internal QA** (stages 3-5 from `docs/reference/component-lifecycle.md`):
|
|
||||||
- Run `/audit` — fix all P0/P1 issues
|
|
||||||
- Run `/critique` — fix all P0/P1 issues
|
|
||||||
- Run `/harden` — check edge cases for interactive molecules
|
|
||||||
- Run `/polish` — final alignment, spacing, transitions
|
|
||||||
- Run `/typeset` if text-heavy (cards, form fields)
|
|
||||||
11. Present to user with summary and scores
|
|
||||||
12. Update all memory files when done
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
name: build-organism
|
|
||||||
description: Build an organism component (Navigation, PricingTable, etc.)
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Build an organism component for the FA Design System.
|
|
||||||
|
|
||||||
Use the component-builder agent to handle this task. The user wants to build the following organism component:
|
|
||||||
|
|
||||||
**Component:** $ARGUMENTS
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read all memory files and conventions before starting
|
|
||||||
2. Check `docs/memory/component-registry.md` — confirm the organism is planned
|
|
||||||
3. Verify all constituent molecules and atoms are marked `done` in the registry — if any are not, STOP and tell the user which dependencies need to be built first
|
|
||||||
4. Create the component in `src/components/organisms/{ComponentName}/`
|
|
||||||
5. Include: `{ComponentName}.tsx`, `{ComponentName}.stories.tsx`, `index.ts`
|
|
||||||
6. Compose from existing molecule and atom components — import from `@molecules/` and `@atoms/`
|
|
||||||
7. Follow all rules in `docs/conventions/component-conventions.md`
|
|
||||||
8. ALL visual values MUST come from the MUI theme — never hardcode
|
|
||||||
9. Write comprehensive Storybook stories with realistic page-level content
|
|
||||||
10. Verify the component renders in Storybook
|
|
||||||
11. **Run internal QA** (stages 3-5 from `docs/reference/component-lifecycle.md`):
|
|
||||||
- Run `/audit` — fix all P0/P1 issues
|
|
||||||
- Run `/critique` — fix all P0/P1 issues
|
|
||||||
- Run `/harden` — check all edge cases (organisms handle real data)
|
|
||||||
- Run `/polish` — final alignment, spacing, transitions
|
|
||||||
- Run `/typeset` if text-heavy
|
|
||||||
- Run `/adapt` — responsive check (organisms are layout components)
|
|
||||||
- Run `/quieter` if the organism handles sensitive moments (pricing, forms, errors)
|
|
||||||
- Run `/clarify` if the organism has decision points or complex information
|
|
||||||
12. Present to user with summary and scores
|
|
||||||
13. Update all memory files when done
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
---
|
|
||||||
name: clarify
|
|
||||||
description: Information hierarchy improvement — evaluates whether primary actions are visible, decision points are clear, content grouping is logical, and labels are unambiguous.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to clarify]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Evaluate and fix information hierarchy, cognitive load, and decision clarity. Unlike audit/critique which only assess, **clarify finds AND fixes issues**. This skill is critical for flows, forms, and any interface where users must make decisions.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design conventions
|
|
||||||
2. Read `docs/memory/decisions-log.md` for design rationale
|
|
||||||
3. Read the target component/area source files and stories
|
|
||||||
4. Reference `docs/reference/impeccable/cognitive-load.md` for the 8-item checklist and violation patterns
|
|
||||||
5. Reference `docs/reference/impeccable/heuristics-scoring.md` for scoring criteria
|
|
||||||
|
|
||||||
**FA context**: Funeral Arranger serves families in grief or distress. Cognitive capacity is reduced under grief — working memory shrinks, decision fatigue sets in faster, and tolerance for ambiguity drops to near zero. Clarity is not a nice-to-have, it is the primary design requirement. No jargon. No ambiguity. No unnecessary choices. Every screen should answer: "What am I looking at? What should I do? What happens next?"
|
|
||||||
|
|
||||||
## Phase 1: Cognitive Load Assessment
|
|
||||||
|
|
||||||
Run the 8-item checklist from `docs/reference/impeccable/cognitive-load.md` against the target:
|
|
||||||
|
|
||||||
| # | Check | Pass/Fail | Notes |
|
|
||||||
|---|-------|-----------|-------|
|
|
||||||
| 1 | **Single focus**: Can the user complete their primary task without distraction? | | |
|
|
||||||
| 2 | **Chunking**: Is information in digestible groups (<=4 items per group)? | | |
|
|
||||||
| 3 | **Grouping**: Are related items visually grouped (proximity, borders, shared background)? | | |
|
|
||||||
| 4 | **Visual hierarchy**: Is it immediately clear what is most important? | | |
|
|
||||||
| 5 | **One thing at a time**: Can the user focus on one decision before the next? | | |
|
|
||||||
| 6 | **Minimal choices**: Are decisions simplified (<=4 visible options at any decision point)? | | |
|
|
||||||
| 7 | **Working memory**: Does the user need to remember info from a previous screen? | | |
|
|
||||||
| 8 | **Progressive disclosure**: Is complexity revealed only when needed? | | |
|
|
||||||
|
|
||||||
**Scoring**: 0-1 failures = low cognitive load (good). 2-3 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
|
|
||||||
|
|
||||||
## Phase 2: Clarity Checks
|
|
||||||
|
|
||||||
Evaluate each dimension. **Fix issues as you find them.**
|
|
||||||
|
|
||||||
### 1. Primary Action Visibility
|
|
||||||
|
|
||||||
The most important action on any screen should be identifiable within 2 seconds.
|
|
||||||
|
|
||||||
- [ ] **One primary action**: There is exactly one visually dominant CTA per view
|
|
||||||
- [ ] **Visual weight**: The primary action has the strongest visual weight (size, colour, contrast)
|
|
||||||
- [ ] **Position**: The primary action is in a predictable location (bottom of form, right of button group)
|
|
||||||
- [ ] **No competition**: Secondary actions are visually subordinate (outlined or text style, not filled)
|
|
||||||
- [ ] **Label clarity**: The CTA label says what will happen ("Continue to payment" not "Next" or "Submit")
|
|
||||||
|
|
||||||
**FA-specific**: For stepped flows (arrangement, funeral finder), the primary CTA must always be visible without scrolling. Smart defaults should pre-fill where possible to reduce friction.
|
|
||||||
|
|
||||||
### 2. Decision Point Clarity
|
|
||||||
|
|
||||||
At every point where the user must choose:
|
|
||||||
|
|
||||||
- [ ] **<=4 options visible**: If more exist, group under categories or use progressive disclosure
|
|
||||||
- [ ] **Recommended option**: When appropriate, visually highlight the recommended choice
|
|
||||||
- [ ] **Clear labels**: Every option has an unambiguous label — the user should never wonder "what does this mean?"
|
|
||||||
- [ ] **Consequence preview**: The user can see what choosing each option will lead to
|
|
||||||
- [ ] **Reversibility signal**: If a choice can be changed later, say so ("You can change this later")
|
|
||||||
|
|
||||||
**FA-specific**: Pricing decisions are high-anxiety. Show what is included, not just the price. For service options, group by category (venue, ceremony, extras) rather than showing a flat list.
|
|
||||||
|
|
||||||
### 3. Content Grouping
|
|
||||||
|
|
||||||
Related information should be visually grouped:
|
|
||||||
|
|
||||||
- [ ] **Proximity**: Related items are physically close together
|
|
||||||
- [ ] **Separation**: Unrelated groups have clear visual separation (whitespace, dividers, card boundaries)
|
|
||||||
- [ ] **Labels**: Each group has a clear heading that describes its contents
|
|
||||||
- [ ] **Consistency**: Same type of grouping used for same type of content throughout
|
|
||||||
|
|
||||||
### 4. Label Clarity
|
|
||||||
|
|
||||||
For every label, heading, and piece of instructional text:
|
|
||||||
|
|
||||||
- [ ] **Unambiguous**: Could not be misinterpreted by a reasonable person
|
|
||||||
- [ ] **Action-oriented**: Buttons describe what they do, not what they are
|
|
||||||
- [ ] **Jargon-free**: No funeral industry jargon unless unavoidable (and defined inline if so)
|
|
||||||
- [ ] **Consistent**: Same concept uses the same word everywhere (not "funeral" in one place and "service" in another for the same thing)
|
|
||||||
- [ ] **Concise**: Labels are as short as possible while remaining clear
|
|
||||||
|
|
||||||
**FA-specific**: Avoid euphemistic language that creates ambiguity. "Funeral service" is clearer than "farewell ceremony." Be direct but warm.
|
|
||||||
|
|
||||||
### 5. Information Hierarchy
|
|
||||||
|
|
||||||
The visual presentation should match the importance of information:
|
|
||||||
|
|
||||||
- [ ] **Reading order**: The most important information comes first (top, left)
|
|
||||||
- [ ] **Size signals importance**: Larger text = more important
|
|
||||||
- [ ] **Colour signals importance**: Brand/accent colour draws the eye to what matters
|
|
||||||
- [ ] **Redundancy eliminated**: No information is repeated without purpose
|
|
||||||
- [ ] **Noise removed**: Every element earns its place — nothing decorative without function
|
|
||||||
|
|
||||||
### 6. Navigation Clarity
|
|
||||||
|
|
||||||
The user should always know where they are and how to move:
|
|
||||||
|
|
||||||
- [ ] **Current location visible**: Active states, breadcrumbs, or step indicators show position
|
|
||||||
- [ ] **Next step obvious**: The path forward is clear without thinking
|
|
||||||
- [ ] **Back path clear**: The user can always go back without losing work
|
|
||||||
- [ ] **Progress visible**: Multi-step flows show progress (step 2 of 4)
|
|
||||||
|
|
||||||
## Phase 3: Cognitive Load Violations Scan
|
|
||||||
|
|
||||||
Check for common cognitive load violations (from `docs/reference/impeccable/cognitive-load.md`):
|
|
||||||
|
|
||||||
1. **Wall of Options** — 10+ choices with no hierarchy. **Fix**: Group, highlight recommended, use progressive disclosure.
|
|
||||||
2. **Memory Bridge** — Must remember info from step 1 to complete step 3. **Fix**: Keep context visible or repeat it.
|
|
||||||
3. **Hidden Navigation** — Must build a mental map. **Fix**: Show current location always.
|
|
||||||
4. **Jargon Barrier** — Technical/domain language forces translation effort. **Fix**: Plain language, define terms inline.
|
|
||||||
5. **Visual Noise Floor** — Every element has the same visual weight. **Fix**: Clear hierarchy — one primary, 2-3 secondary, rest muted.
|
|
||||||
6. **Inconsistent Pattern** — Similar actions work differently in different places. **Fix**: Standardise interaction patterns.
|
|
||||||
7. **Multi-Task Demand** — Must process multiple simultaneous inputs. **Fix**: Sequence the steps.
|
|
||||||
8. **Context Switch** — Must jump between screens to gather info for one decision. **Fix**: Co-locate information.
|
|
||||||
|
|
||||||
## Clarification Report
|
|
||||||
|
|
||||||
After fixing issues, present:
|
|
||||||
|
|
||||||
### Cognitive Load Score
|
|
||||||
|
|
||||||
**Checklist failures**: X/8
|
|
||||||
**Rating**: Low / Moderate / High
|
|
||||||
|
|
||||||
### What Was Clarified
|
|
||||||
|
|
||||||
For each fix:
|
|
||||||
- **Area**: What was changed
|
|
||||||
- **Problem**: What was unclear or overloaded
|
|
||||||
- **Fix**: What was done to clarify
|
|
||||||
- **Rationale**: Why this improves the experience for FA's users
|
|
||||||
|
|
||||||
### Clarity Wins
|
|
||||||
|
|
||||||
Note areas that are already well-structured — reinforce good patterns.
|
|
||||||
|
|
||||||
### Remaining Concerns
|
|
||||||
|
|
||||||
Note any structural clarity issues that require architectural changes beyond this skill's scope.
|
|
||||||
|
|
||||||
**NEVER**:
|
|
||||||
- Add jargon or technical language — always simplify
|
|
||||||
- Present more than 4 options at a decision point without grouping
|
|
||||||
- Remove information that users need — clarify means restructure, not delete
|
|
||||||
- Change component behaviour or API — only change presentation, labels, grouping, and hierarchy
|
|
||||||
- Use ambiguous labels ("Submit", "Click here", "Next") when specific labels are possible
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-tokens
|
|
||||||
description: Create design tokens from brand colours, fonts, and reference material
|
|
||||||
argument-hint: "[brand colours, fonts, or Figma URL]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Create design tokens for the FA Design System.
|
|
||||||
|
|
||||||
Use the token-architect agent to handle this task. The user's input follows — it may include brand colours, font choices, reference images, or Figma URLs.
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read all memory files and conventions before starting
|
|
||||||
2. If the user provides a Figma URL, use the Figma MCP to extract design context
|
|
||||||
3. Create primitive tokens (colour scales, typography, spacing, effects)
|
|
||||||
4. Create semantic tokens (map primitives to design intent)
|
|
||||||
5. Run `npm run build:tokens` to generate outputs
|
|
||||||
6. Update the MUI theme in `src/theme/index.ts` to use generated values
|
|
||||||
7. Update all memory files when done
|
|
||||||
|
|
||||||
User input: $ARGUMENTS
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
---
|
|
||||||
name: critique
|
|
||||||
description: UX design review — evaluates visual hierarchy, emotional journey, cognitive load, and overall quality using Nielsen's heuristics (scored 0-40). Adapted from impeccable (Apache 2.0).
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to critique]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Conduct a holistic UX design critique. Think like a design director giving feedback — evaluate whether the interface actually works as a designed experience, not just technically.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design context and brand guidelines
|
|
||||||
2. Read `docs/memory/decisions-log.md` for design rationale
|
|
||||||
3. Read the target component/area source and stories
|
|
||||||
4. Reference `docs/reference/impeccable/cognitive-load.md` for the 8-item checklist
|
|
||||||
5. Reference `docs/reference/impeccable/heuristics-scoring.md` for scoring criteria
|
|
||||||
|
|
||||||
**FA context reminder**: Funeral Arranger serves families often in grief or distress. The design must feel warm, trustworthy, transparent, and calm. Clarity over cleverness. Accessibility is critical — users may be elderly, emotional, or unfamiliar with technology.
|
|
||||||
|
|
||||||
## Phase 1: Design Critique
|
|
||||||
|
|
||||||
Evaluate across these dimensions:
|
|
||||||
|
|
||||||
### 1. Visual Hierarchy
|
|
||||||
- Does the eye flow to the most important element first?
|
|
||||||
- Is there a clear primary action visible within 2 seconds?
|
|
||||||
- Do size, colour, and position communicate importance correctly?
|
|
||||||
- Is there visual competition between elements of different weights?
|
|
||||||
|
|
||||||
### 2. Information Architecture & Cognitive Load
|
|
||||||
- Is the structure intuitive for a first-time user?
|
|
||||||
- Is related content grouped logically?
|
|
||||||
- Are there too many choices at once? (>4 at a decision point = flag it)
|
|
||||||
- **Run the 8-item cognitive load checklist** from `docs/reference/impeccable/cognitive-load.md`
|
|
||||||
- Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical
|
|
||||||
|
|
||||||
### 3. Emotional Journey
|
|
||||||
- Does the interface feel warm and trustworthy (appropriate for FA)?
|
|
||||||
- Would a grieving family member feel "this is for me"?
|
|
||||||
- Are there design interventions at anxiety-prone moments (pricing, commitment, forms)?
|
|
||||||
- Does the experience end well (confirmation, clear next step)?
|
|
||||||
|
|
||||||
### 4. Discoverability & Affordance
|
|
||||||
- Are interactive elements obviously interactive?
|
|
||||||
- Would a user know what to do without instructions?
|
|
||||||
- Are hover/focus states providing useful feedback?
|
|
||||||
|
|
||||||
### 5. Composition & Balance
|
|
||||||
- Does the layout feel balanced?
|
|
||||||
- Is whitespace used intentionally?
|
|
||||||
- Is there visual rhythm in spacing and repetition?
|
|
||||||
|
|
||||||
### 6. Typography as Communication
|
|
||||||
- Does the type hierarchy signal what to read first, second, third?
|
|
||||||
- Is body text comfortable to read? (line length 45-75ch, adequate size)
|
|
||||||
- Do font choices reinforce FA's warm, professional tone?
|
|
||||||
|
|
||||||
### 7. Colour with Purpose
|
|
||||||
- Is colour used to communicate, not just decorate?
|
|
||||||
- Does the warm gold/copper brand palette feel cohesive?
|
|
||||||
- Are accent colours drawing attention to the right things?
|
|
||||||
- Does it work for colourblind users?
|
|
||||||
|
|
||||||
### 8. States & Edge Cases
|
|
||||||
- Empty states: Do they guide users toward action?
|
|
||||||
- Loading states: Do they reduce perceived wait time?
|
|
||||||
- Error states: Are they helpful and non-blaming? (critical for FA — no aggressive red labels)
|
|
||||||
- Success states: Do they confirm and guide next steps?
|
|
||||||
|
|
||||||
### 9. Microcopy & Voice
|
|
||||||
- Is the writing clear and concise?
|
|
||||||
- Does it sound warm and professional (FA's tone)?
|
|
||||||
- Are labels and buttons unambiguous?
|
|
||||||
- Does error copy help users fix the problem without distress?
|
|
||||||
|
|
||||||
## Phase 2: Present Findings
|
|
||||||
|
|
||||||
### Design Health Score
|
|
||||||
|
|
||||||
Score each of Nielsen's 10 heuristics 0-4 (consult `docs/reference/impeccable/heuristics-scoring.md`):
|
|
||||||
|
|
||||||
| # | Heuristic | Score | Key Issue |
|
|
||||||
|---|-----------|-------|-----------|
|
|
||||||
| 1 | Visibility of System Status | ? | |
|
|
||||||
| 2 | Match System / Real World | ? | |
|
|
||||||
| 3 | User Control and Freedom | ? | |
|
|
||||||
| 4 | Consistency and Standards | ? | |
|
|
||||||
| 5 | Error Prevention | ? | |
|
|
||||||
| 6 | Recognition Rather Than Recall | ? | |
|
|
||||||
| 7 | Flexibility and Efficiency | ? | |
|
|
||||||
| 8 | Aesthetic and Minimalist Design | ? | |
|
|
||||||
| 9 | Error Recovery | ? | |
|
|
||||||
| 10 | Help and Documentation | ? | |
|
|
||||||
| **Total** | | **??/40** | **[Rating]** |
|
|
||||||
|
|
||||||
**Ratings**: 36-40 Excellent, 28-35 Good, 20-27 Acceptable, 12-19 Poor, 0-11 Critical
|
|
||||||
|
|
||||||
### Overall Impression
|
|
||||||
Brief gut reaction — what works, what doesn't, the single biggest opportunity.
|
|
||||||
|
|
||||||
### What's Working
|
|
||||||
2-3 things done well. Be specific about why they work.
|
|
||||||
|
|
||||||
### Priority Issues
|
|
||||||
3-5 most impactful design problems, ordered by importance.
|
|
||||||
|
|
||||||
For each issue, tag **P0-P3**:
|
|
||||||
- **[P?] What**: Name the problem
|
|
||||||
- **Why it matters**: How it hurts users (especially in FA's sensitive context)
|
|
||||||
- **Fix**: Concrete recommendation
|
|
||||||
|
|
||||||
### FA Audience Check
|
|
||||||
Walk through the primary user flow as:
|
|
||||||
1. **Bereaved family member** (60+, first time, emotional, possibly on mobile)
|
|
||||||
2. **Arrangement planner** (30-50, comparing options, price-sensitive, wants transparency)
|
|
||||||
|
|
||||||
For each persona, list specific pain points found.
|
|
||||||
|
|
||||||
### Minor Observations
|
|
||||||
Quick notes on smaller issues worth addressing.
|
|
||||||
|
|
||||||
**Remember**: Be direct and specific. Say what's wrong AND why it matters. Prioritise ruthlessly. Don't soften criticism — honest feedback ships better design.
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
name: harden
|
|
||||||
description: Edge case and robustness review — checks error states, empty states, loading states, boundary values, and disabled interactions. Critical for forms and arrangement flows.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to harden]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Systematically review and fix edge cases, error states, and boundary conditions. Unlike audit/critique which only assess, **harden finds AND fixes issues**. This skill is especially critical for forms, stepped flows, and anything involving user input.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design conventions
|
|
||||||
2. Read `docs/memory/decisions-log.md` for design rationale (especially D024 on error states)
|
|
||||||
3. Read the target component/area source files and stories
|
|
||||||
4. Reference `docs/reference/impeccable/interaction-design.md` for interactive state requirements
|
|
||||||
5. Reference `docs/reference/impeccable/cognitive-load.md` for cognitive load under stress
|
|
||||||
6. Reference `docs/conventions/component-conventions.md` for structural rules
|
|
||||||
|
|
||||||
**FA context**: Funeral Arranger serves families in grief or distress. When something goes wrong, the interface must be gentle and guiding, never blaming or alarming. Error states use copper tones, not red (D024). Empty states should guide toward action, not leave users stranded. Loading states should reduce perceived wait time — grieving users have less patience for ambiguity.
|
|
||||||
|
|
||||||
## Hardening Checklist
|
|
||||||
|
|
||||||
Work through each category. **Fix issues as you find them** — do not just document.
|
|
||||||
|
|
||||||
### 1. Error States
|
|
||||||
|
|
||||||
For every input, form field, and async operation:
|
|
||||||
|
|
||||||
- [ ] **Error is visible**: Error message appears near the source of the problem
|
|
||||||
- [ ] **Error is gentle**: Uses copper tones (`feedback.error`), not aggressive red. Per D024, labels stay neutral
|
|
||||||
- [ ] **Error is specific**: Identifies the exact problem ("Please enter a valid email" not "Invalid input")
|
|
||||||
- [ ] **Error is actionable**: Tells the user how to fix it
|
|
||||||
- [ ] **Error is accessible**: Connected via `aria-describedby`, announced to screen readers
|
|
||||||
- [ ] **Error does not destroy work**: Form data is preserved when validation fails
|
|
||||||
- [ ] **Error timing**: Validates on blur for individual fields, on submit for cross-field validation
|
|
||||||
- [ ] **Network errors**: Graceful message for failed API calls with retry option
|
|
||||||
- [ ] **Unexpected errors**: Catch-all error boundary that does not show a blank screen
|
|
||||||
|
|
||||||
**FA-specific**: Error copy should never blame the user. Use passive voice for errors ("A valid email is needed" not "You entered an invalid email"). Offer help where possible.
|
|
||||||
|
|
||||||
### 2. Empty States
|
|
||||||
|
|
||||||
For every list, collection, search result, and data display:
|
|
||||||
|
|
||||||
- [ ] **Empty state exists**: Not just blank space or a bare container
|
|
||||||
- [ ] **Empty state guides**: Tells the user what this area is for and how to populate it
|
|
||||||
- [ ] **Empty state has a CTA**: Primary action to add/create/search is visible
|
|
||||||
- [ ] **Empty state feels warm**: Consistent with FA's supportive tone
|
|
||||||
- [ ] **Empty state is distinct**: Clearly different from loading state — user should never confuse "no data" with "still loading"
|
|
||||||
|
|
||||||
### 3. Loading States
|
|
||||||
|
|
||||||
For every async operation and data fetch:
|
|
||||||
|
|
||||||
- [ ] **Loading indicator exists**: User sees feedback that something is happening
|
|
||||||
- [ ] **Skeleton over spinner**: Use skeleton screens for content areas, spinners only for actions
|
|
||||||
- [ ] **No layout shift**: Content area maintains its dimensions during loading (prevents CLS)
|
|
||||||
- [ ] **Loading is fast-feeling**: Skeleton previews the content shape; perceived wait is minimised
|
|
||||||
- [ ] **Loading timeout**: If loading takes >5s, show a reassuring message ("This is taking longer than usual")
|
|
||||||
- [ ] **Button loading**: Buttons show inline loading state, remain disabled, and preserve their width
|
|
||||||
- [ ] **Optimistic updates**: For low-stakes actions, show success immediately and rollback on failure
|
|
||||||
|
|
||||||
### 4. Disabled States
|
|
||||||
|
|
||||||
For every interactive element that can be disabled:
|
|
||||||
|
|
||||||
- [ ] **Visually distinct**: Reduced opacity (0.38-0.5) or muted treatment — clearly non-interactive
|
|
||||||
- [ ] **`aria-disabled`**: Set alongside visual treatment for screen reader users
|
|
||||||
- [ ] **No pointer events**: `pointer-events: none` or equivalent — no hover/active states
|
|
||||||
- [ ] **Tooltip on disabled**: Explains WHY the element is disabled (e.g., "Complete the required fields first")
|
|
||||||
- [ ] **Cursor**: Shows `not-allowed` cursor on hover
|
|
||||||
|
|
||||||
### 5. Boundary Values
|
|
||||||
|
|
||||||
For every input that accepts user data:
|
|
||||||
|
|
||||||
- [ ] **Max length**: Text inputs have sensible `maxLength` and show remaining characters if relevant
|
|
||||||
- [ ] **Min/max values**: Number inputs have `min`/`max` attributes
|
|
||||||
- [ ] **Long content**: Component handles very long names, descriptions, and values without breaking layout
|
|
||||||
- [ ] **Short content**: Component handles single-character or minimal content gracefully
|
|
||||||
- [ ] **Special characters**: Handles ampersands, quotes, HTML entities, and emoji without rendering issues
|
|
||||||
- [ ] **Zero state**: Numeric displays handle $0.00, 0 items, 0 results
|
|
||||||
- [ ] **Large numbers**: Handles $999,999+ with proper formatting
|
|
||||||
|
|
||||||
### 6. Overflow & Truncation
|
|
||||||
|
|
||||||
For every text container and layout:
|
|
||||||
|
|
||||||
- [ ] **Text overflow**: Long text truncates with ellipsis or wraps gracefully — never overflows container
|
|
||||||
- [ ] **Responsive overflow**: No horizontal scroll at any viewport width
|
|
||||||
- [ ] **List overflow**: Long lists scroll within a container, not the page
|
|
||||||
- [ ] **Image overflow**: Images are constrained to their containers with `object-fit`
|
|
||||||
|
|
||||||
### 7. Keyboard & Focus
|
|
||||||
|
|
||||||
For every interactive element and flow:
|
|
||||||
|
|
||||||
- [ ] **Tab order**: Logical, matches visual order
|
|
||||||
- [ ] **Focus trap**: Modals and drawers trap focus correctly (use `inert` on background)
|
|
||||||
- [ ] **Focus return**: When a modal/popover closes, focus returns to the trigger element
|
|
||||||
- [ ] **Escape to close**: All overlays close on Escape key
|
|
||||||
- [ ] **Enter to submit**: Forms submit on Enter from the last field
|
|
||||||
- [ ] **Arrow navigation**: Tab lists, menus, and radio groups use roving tabindex with arrow keys
|
|
||||||
|
|
||||||
### 8. Concurrent & Async
|
|
||||||
|
|
||||||
For forms and flows with async operations:
|
|
||||||
|
|
||||||
- [ ] **Double-submit prevention**: Submit button disables after first click
|
|
||||||
- [ ] **Rapid interaction**: Debounced search, throttled scroll handlers
|
|
||||||
- [ ] **Stale data**: Component handles data that changes between render and interaction
|
|
||||||
- [ ] **Unmount safety**: Async operations clean up on component unmount (no state updates after unmount)
|
|
||||||
|
|
||||||
## Hardening Report
|
|
||||||
|
|
||||||
After fixing issues, provide a summary:
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
List each issue fixed with a one-line description.
|
|
||||||
|
|
||||||
### Verified OK
|
|
||||||
List categories that passed inspection without changes needed.
|
|
||||||
|
|
||||||
### Out of Scope
|
|
||||||
Note any structural issues found that require architectural changes (not hardening work).
|
|
||||||
|
|
||||||
**NEVER**:
|
|
||||||
- Use aggressive red for error states — always copper/warm tones per D024
|
|
||||||
- Show blank screens for empty or error states
|
|
||||||
- Leave async operations without loading feedback
|
|
||||||
- Allow double-submit on forms
|
|
||||||
- Remove focus indicators
|
|
||||||
- Make structural changes — hardening fixes edge cases within the existing architecture
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
name: normalize
|
|
||||||
description: Cross-component consistency scan — checks token access patterns, transitions, focus styles, spacing methods, and displayName across all components in a tier or the entire system.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[tier (atoms/molecules/organisms) or 'all']"
|
|
||||||
---
|
|
||||||
|
|
||||||
Scan all components in a tier (or the entire system) for consistency violations and fix them. Unlike audit/critique which evaluate individual components, **normalize ensures the system behaves as one cohesive whole**. This skill finds AND fixes issues.
|
|
||||||
|
|
||||||
**Target tier:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design conventions
|
|
||||||
2. Read `docs/memory/decisions-log.md` for design rationale (especially D031 on token access)
|
|
||||||
3. Read `docs/conventions/component-conventions.md` for structural rules
|
|
||||||
4. List all component files in the target tier(s):
|
|
||||||
- Atoms: `src/components/atoms/*/`
|
|
||||||
- Molecules: `src/components/molecules/*/`
|
|
||||||
- Organisms: `src/components/organisms/*/`
|
|
||||||
- If target is "all", scan every tier
|
|
||||||
|
|
||||||
**FA context**: Consistency is trust. When components behave differently for no reason — different transition speeds, different focus styles, different spacing methods — users sense it even if they cannot articulate it. For families in distress, inconsistency creates subconscious unease. Normalize ruthlessly.
|
|
||||||
|
|
||||||
## Consistency Dimensions
|
|
||||||
|
|
||||||
For each dimension, scan ALL components in the target scope, compare patterns, identify outliers, and fix them to match the established convention.
|
|
||||||
|
|
||||||
### 1. Token Access Patterns (D031)
|
|
||||||
|
|
||||||
**Convention**: Two access methods, by token tier:
|
|
||||||
- **Semantic tokens** (colour, spacing, typography, shape): `theme.palette.*`, `theme.spacing()`, `theme.typography.*`, `theme.shape.*` inside theme callbacks. `var(--fa-color-*)`, `var(--fa-spacing-*)` in static contexts.
|
|
||||||
- **Component tokens** (badge sizes, card shadows, input dimensions): `var(--fa-badge-*)`, `var(--fa-card-*)` CSS variables only — these are NOT in the MUI theme.
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Hardcoded hex/rgb colour values (should use theme or CSS var)
|
|
||||||
- [ ] Hardcoded px spacing values (should use `theme.spacing()` or `var(--fa-spacing-*)`)
|
|
||||||
- [ ] Hardcoded font sizes or weights (should use `theme.typography.*`)
|
|
||||||
- [ ] Semantic tokens accessed via CSS var when inside a theme callback (prefer theme accessor)
|
|
||||||
- [ ] Component tokens accessed via theme (they are CSS vars only)
|
|
||||||
- [ ] Primitive tokens used directly instead of semantic tokens
|
|
||||||
|
|
||||||
### 2. Transition Timing
|
|
||||||
|
|
||||||
**Convention**: 150ms ease-in-out for all state transitions.
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Transitions using durations other than 150ms (or `theme.transitions.duration.short`)
|
|
||||||
- [ ] Transitions using easing other than ease-in-out
|
|
||||||
- [ ] Transitions using bounce, elastic, or spring easing (remove — feels dated)
|
|
||||||
- [ ] Missing transitions on interactive state changes (hover, focus, active)
|
|
||||||
- [ ] Transitions on layout properties (`width`, `height`, `top`, `left`) instead of `transform`/`opacity`
|
|
||||||
|
|
||||||
### 3. Focus-Visible Style
|
|
||||||
|
|
||||||
**Convention**: `:focus-visible` with 2px outline, offset 2px, high contrast (3:1 minimum against adjacent colours).
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] `outline: none` without `:focus-visible` replacement
|
|
||||||
- [ ] Focus styles on `:focus` instead of `:focus-visible` (shows ring on click)
|
|
||||||
- [ ] Inconsistent outline width, colour, or offset across components
|
|
||||||
- [ ] Missing focus styles entirely on interactive elements
|
|
||||||
- [ ] Focus ring colour that does not meet 3:1 contrast
|
|
||||||
|
|
||||||
### 4. Spacing Method
|
|
||||||
|
|
||||||
**Convention**: `theme.spacing()` in styled components and `sx` props. `var(--fa-spacing-*)` in static contexts.
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Raw pixel values for padding/margin/gap (e.g., `padding: '16px'`)
|
|
||||||
- [ ] Mixed methods in the same component (some `theme.spacing()`, some raw values)
|
|
||||||
- [ ] Inconsistent spacing scale usage across similar components (e.g., one card uses `spacing(2)` for padding, another uses `spacing(3)`)
|
|
||||||
|
|
||||||
### 5. Component Structure
|
|
||||||
|
|
||||||
**Convention**: Per `docs/conventions/component-conventions.md`.
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Missing `displayName` on the component
|
|
||||||
- [ ] Missing barrel export (`index.ts` with `export { default }` and `export *`)
|
|
||||||
- [ ] Missing JSDoc on props interface (every prop needs a `/** description */`)
|
|
||||||
- [ ] Missing JSDoc on the component itself
|
|
||||||
- [ ] Props defined as `type` instead of `interface` (interfaces produce better autodocs)
|
|
||||||
- [ ] Missing `sx` prop forwarding (every component must accept consumer overrides)
|
|
||||||
- [ ] Interactive elements not using `React.forwardRef`
|
|
||||||
|
|
||||||
### 6. Story Coverage
|
|
||||||
|
|
||||||
**Convention**: Per story coverage checklist in `docs/conventions/component-conventions.md`.
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Missing `tags: ['autodocs']` in story meta
|
|
||||||
- [ ] Missing Default story
|
|
||||||
- [ ] Missing AllVariants story (if component has variants)
|
|
||||||
- [ ] Missing Disabled story (if component can be disabled)
|
|
||||||
- [ ] Missing Loading story (if component has loading state)
|
|
||||||
- [ ] Incorrect `title` prefix (should match tier: `Atoms/`, `Molecules/`, `Organisms/`)
|
|
||||||
|
|
||||||
### 7. Naming Consistency
|
|
||||||
|
|
||||||
**Scan for violations**:
|
|
||||||
- [ ] Component folder not in PascalCase
|
|
||||||
- [ ] File names not matching component name
|
|
||||||
- [ ] Inconsistent prop naming across similar components (e.g., `isDisabled` vs `disabled`, `colour` vs `color`)
|
|
||||||
- [ ] CSS custom properties not prefixed with `--fa-`
|
|
||||||
|
|
||||||
## Normalize Process
|
|
||||||
|
|
||||||
1. **Scan**: Read every component file in the target tier(s)
|
|
||||||
2. **Tabulate**: Build a comparison table for each dimension showing what each component does
|
|
||||||
3. **Identify outliers**: Find components that deviate from the convention
|
|
||||||
4. **Fix**: Update outlier components to match the convention
|
|
||||||
5. **Verify**: Ensure TypeScript compiles and Storybook renders after fixes
|
|
||||||
|
|
||||||
## Normalization Report
|
|
||||||
|
|
||||||
Present findings in this format:
|
|
||||||
|
|
||||||
### Scan Summary
|
|
||||||
|
|
||||||
| Dimension | Components scanned | Violations found | Fixed |
|
|
||||||
|-----------|-------------------|-----------------|-------|
|
|
||||||
| Token access | ? | ? | ? |
|
|
||||||
| Transitions | ? | ? | ? |
|
|
||||||
| Focus styles | ? | ? | ? |
|
|
||||||
| Spacing | ? | ? | ? |
|
|
||||||
| Structure | ? | ? | ? |
|
|
||||||
| Stories | ? | ? | ? |
|
|
||||||
| Naming | ? | ? | ? |
|
|
||||||
|
|
||||||
### Violations Fixed
|
|
||||||
|
|
||||||
For each fix, note:
|
|
||||||
- **Component**: Which component was changed
|
|
||||||
- **Dimension**: Which consistency rule was violated
|
|
||||||
- **Before**: What it was doing
|
|
||||||
- **After**: What it does now
|
|
||||||
|
|
||||||
### System-Level Observations
|
|
||||||
|
|
||||||
Note any patterns that suggest a convention should be updated (e.g., if most components deviate from the convention, perhaps the convention is wrong).
|
|
||||||
|
|
||||||
**NEVER**:
|
|
||||||
- Change a component's behaviour or API — normalize only changes implementation details
|
|
||||||
- Fix one component and leave similar components unfixed — normalize the whole tier
|
|
||||||
- Change conventions without flagging it — if you think a convention should change, note it as an observation, do not unilaterally change the rule
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
name: polish
|
|
||||||
description: Final production readiness pass — visual alignment, spacing consistency, interaction states, copy, edge cases. Use before marking a component done.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to polish]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Perform a meticulous final pass on a component or area, fixing every detail that separates good work from great work. Unlike audit/critique which only assess, **polish finds AND fixes issues**.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA design conventions
|
|
||||||
2. Read `docs/memory/decisions-log.md` for design rationale and prior decisions
|
|
||||||
3. Read the target component/area source files and stories
|
|
||||||
4. Reference `docs/reference/impeccable/polish-skill.md` for the full checklist
|
|
||||||
5. Reference `docs/conventions/component-conventions.md` for structural rules
|
|
||||||
|
|
||||||
**FA context**: Funeral Arranger serves families in grief or distress. The design must feel warm, trustworthy, and calm. Touch targets >= 44px. Transitions 150ms ease-in-out. No aggressive colours for errors (copper, not red — see D024). Accessibility is critical — users may be elderly, emotional, or on mobile.
|
|
||||||
|
|
||||||
**CRITICAL**: Polish is the last step, not the first. Do not polish work that is not functionally complete. If the component has open TODOs or missing features, flag them and skip those areas.
|
|
||||||
|
|
||||||
## Pre-Polish Assessment
|
|
||||||
|
|
||||||
Before fixing anything, assess the current state:
|
|
||||||
|
|
||||||
1. **Review completeness** — Is the component functionally complete? Are there known issues to preserve (mark with TODOs)?
|
|
||||||
2. **Identify polish areas** — Scan for visual inconsistencies, spacing issues, interaction state gaps, copy problems, edge cases, and transition roughness.
|
|
||||||
3. **Set scope** — List the specific fixes to make. Do not attempt structural changes — polish is micro-detail work.
|
|
||||||
|
|
||||||
## Polish Systematically
|
|
||||||
|
|
||||||
Work through each dimension. **Fix issues as you find them** — do not just document.
|
|
||||||
|
|
||||||
### 1. Visual Alignment & Spacing
|
|
||||||
|
|
||||||
- **Grid alignment**: All elements line up to the spacing scale (no arbitrary gaps)
|
|
||||||
- **Consistent spacing**: Every gap uses `theme.spacing()` or `var(--fa-spacing-*)` — no magic numbers
|
|
||||||
- **Optical alignment**: Adjust for visual weight where needed (icons often need offset for optical centering)
|
|
||||||
- **Responsive consistency**: Spacing works at all breakpoints (mobile, tablet, desktop)
|
|
||||||
|
|
||||||
**How to check**: Inspect computed styles. Look for values that are not multiples of 4px or 8px. Squint at the layout — anything that feels off probably is.
|
|
||||||
|
|
||||||
### 2. Typography Refinement
|
|
||||||
|
|
||||||
- **Hierarchy consistency**: Same element types use the same typography variant throughout
|
|
||||||
- **Line length**: Body text stays within 45-75 characters
|
|
||||||
- **Line height**: Appropriate for font size and reading context
|
|
||||||
- **Token compliance**: All font sizes, weights, and families come from `theme.typography.*`
|
|
||||||
|
|
||||||
### 3. Colour & Contrast
|
|
||||||
|
|
||||||
- **Contrast ratios**: All text meets WCAG AA (4.5:1 for normal text, 3:1 for large text and UI)
|
|
||||||
- **Token usage**: No hardcoded hex values — all via theme palette or CSS variables
|
|
||||||
- **Tinted neutrals**: No pure gray or pure black — add subtle colour tint per FA palette
|
|
||||||
- **Gray on colour**: Never put gray text on coloured backgrounds — use a shade of that colour
|
|
||||||
|
|
||||||
### 4. Interaction States
|
|
||||||
|
|
||||||
Every interactive element must have ALL of these states:
|
|
||||||
|
|
||||||
| State | Treatment | FA note |
|
|
||||||
|-------|-----------|---------|
|
|
||||||
| Default | Base styling | Warm, inviting |
|
|
||||||
| Hover | Subtle lift, colour shift | 150ms ease-in-out |
|
|
||||||
| Focus | Visible ring via `:focus-visible` | 2px, offset 2px, high contrast |
|
|
||||||
| Active | Pressed/darker | Immediate feedback |
|
|
||||||
| Disabled | Reduced opacity, `aria-disabled` | Clearly non-interactive |
|
|
||||||
| Loading | Spinner or skeleton | Reduce perceived wait |
|
|
||||||
| Error | Copper border/text, not red | Gentle, per D024 |
|
|
||||||
| Success | Confirmation feedback | Reassuring, not flashy |
|
|
||||||
|
|
||||||
**The common miss**: Designing hover without focus. Keyboard users never see hover states.
|
|
||||||
|
|
||||||
### 5. Micro-interactions & Transitions
|
|
||||||
|
|
||||||
- **Timing**: All transitions 150ms ease-in-out (FA convention)
|
|
||||||
- **Properties**: Only animate `transform`, `opacity`, `background-color`, `border-color`, `box-shadow` — never animate `width`, `height`, or `top`/`left`
|
|
||||||
- **Reduced motion**: Must respect `prefers-reduced-motion`
|
|
||||||
- **Easing**: Ease-in-out or ease-out. Never bounce or elastic — they feel dated.
|
|
||||||
|
|
||||||
### 6. Copy & Content
|
|
||||||
|
|
||||||
- **Consistent terminology**: Same things called the same names throughout
|
|
||||||
- **Capitalisation**: Sentence case for body, consistent for labels
|
|
||||||
- **Tone**: Warm, professional, clear — no jargon, no condescension
|
|
||||||
- **Labels**: Unambiguous — a user should never wonder "what does this mean?"
|
|
||||||
- **Punctuation**: Consistent (periods on sentences, not on labels)
|
|
||||||
|
|
||||||
### 7. Edge Cases
|
|
||||||
|
|
||||||
- **Long content**: Handles very long names, descriptions, prices
|
|
||||||
- **Empty states**: Helpful guidance, not blank space
|
|
||||||
- **Missing data**: Graceful degradation with sensible defaults
|
|
||||||
- **Loading states**: Clear async feedback, skeleton over spinner where possible
|
|
||||||
- **Error states**: Helpful, non-blaming messages with recovery paths (copper, not red)
|
|
||||||
|
|
||||||
### 8. Code Cleanliness
|
|
||||||
|
|
||||||
- Remove `console.log` statements
|
|
||||||
- Remove commented-out code
|
|
||||||
- Remove unused imports
|
|
||||||
- Verify `displayName` is set on the component
|
|
||||||
- Verify barrel export in `index.ts`
|
|
||||||
- Ensure all props have JSDoc descriptions
|
|
||||||
- No TypeScript `any` types
|
|
||||||
|
|
||||||
## Polish Checklist
|
|
||||||
|
|
||||||
Verify each item after completing fixes:
|
|
||||||
|
|
||||||
- [ ] Visual alignment correct at all breakpoints
|
|
||||||
- [ ] Spacing uses design tokens consistently (no magic numbers)
|
|
||||||
- [ ] Typography hierarchy consistent
|
|
||||||
- [ ] All interactive states implemented (hover, focus, active, disabled, loading, error)
|
|
||||||
- [ ] All transitions 150ms ease-in-out
|
|
||||||
- [ ] Focus indicators visible via `:focus-visible`
|
|
||||||
- [ ] Copy is consistent, warm, and unambiguous
|
|
||||||
- [ ] Touch targets >= 44px on all interactive elements
|
|
||||||
- [ ] Contrast ratios meet WCAG AA
|
|
||||||
- [ ] Keyboard navigation works correctly
|
|
||||||
- [ ] No console errors or warnings
|
|
||||||
- [ ] No layout shift on load
|
|
||||||
- [ ] Respects `prefers-reduced-motion`
|
|
||||||
- [ ] Code is clean (no TODOs, console.logs, commented code, unused imports)
|
|
||||||
- [ ] Component has `displayName` and barrel export
|
|
||||||
- [ ] All props have JSDoc descriptions
|
|
||||||
|
|
||||||
## Final Verification
|
|
||||||
|
|
||||||
After all fixes:
|
|
||||||
|
|
||||||
1. **Check Storybook** — Verify all stories render correctly
|
|
||||||
2. **Check TypeScript** — Ensure no type errors introduced
|
|
||||||
3. **Compare states** — Walk through every interactive state visually
|
|
||||||
4. **Test keyboard** — Tab through the component, verify focus order and indicators
|
|
||||||
|
|
||||||
**NEVER**:
|
|
||||||
- Polish before the component is functionally complete
|
|
||||||
- Introduce bugs while polishing (test thoroughly after each fix)
|
|
||||||
- Ignore systematic issues (if spacing is off everywhere, fix the system not individual instances)
|
|
||||||
- Perfect one area while leaving others rough (maintain consistent quality level)
|
|
||||||
- Make structural or architectural changes — that is not polish work
|
|
||||||
|
|
||||||
Report a summary of what was fixed when done.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
---
|
|
||||||
name: preflight
|
|
||||||
description: Pre-commit quality check — verifies TypeScript, ESLint, Prettier, Storybook, token sync, and no hardcoded values
|
|
||||||
argument-hint: "[--fix to auto-fix issues]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Run quality checks before committing. Reports pass/fail for each check and blocks commit if critical issues are found.
|
|
||||||
|
|
||||||
**Mode:** $ARGUMENTS (pass `--fix` to attempt auto-fixes, otherwise report-only)
|
|
||||||
|
|
||||||
**Run these checks in order:**
|
|
||||||
|
|
||||||
### 1. TypeScript compilation
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit 2>&1 | grep -v "tokens.js"
|
|
||||||
```
|
|
||||||
- **Pass:** No errors (the tokens.js declaration warning is a known pre-existing issue — ignore it)
|
|
||||||
- **Fail:** Any other TypeScript errors → report them
|
|
||||||
- **Critical:** Yes — do not commit if this fails
|
|
||||||
|
|
||||||
### 2. Storybook build
|
|
||||||
```bash
|
|
||||||
npx storybook build --quiet 2>&1
|
|
||||||
```
|
|
||||||
- **Pass:** Build succeeds
|
|
||||||
- **Fail:** Build errors → report them
|
|
||||||
- **Critical:** Yes — do not commit if this fails
|
|
||||||
|
|
||||||
### 3. Token sync check
|
|
||||||
Compare timestamps: do the generated outputs (`src/theme/generated/tokens.js`, `src/theme/generated/tokens.css`) have an older modification time than any `tokens/**/*.json` file?
|
|
||||||
```bash
|
|
||||||
# Find newest token JSON file
|
|
||||||
newest_token=$(find tokens/ -name "*.json" -newer src/theme/generated/tokens.js 2>/dev/null | head -5)
|
|
||||||
```
|
|
||||||
- **Pass:** No token JSON files are newer than the generated outputs
|
|
||||||
- **Fail:** Token JSON was modified but outputs weren't regenerated
|
|
||||||
- **Fix:** Run `npm run build:tokens`
|
|
||||||
- **Critical:** Yes — stale generated tokens cause silent bugs
|
|
||||||
|
|
||||||
### 4. Hardcoded values scan
|
|
||||||
Scan component files for hardcoded colours, spacing, and font values that should use tokens:
|
|
||||||
```bash
|
|
||||||
# Check for hex colours in component files (excluding stories and tokens)
|
|
||||||
grep -rn "#[0-9a-fA-F]\{3,8\}" src/components/ --include="*.tsx" --include="*.ts" | grep -v ".stories." | grep -v "// ok-hardcode"
|
|
||||||
```
|
|
||||||
- **Pass:** No hex colours found in component source files (stories are exempt)
|
|
||||||
- **Fail:** Hardcoded values found → report file and line
|
|
||||||
- **Note:** Lines with `// ok-hardcode` comment are exempted (for rare intentional cases)
|
|
||||||
- **Critical:** No — warn but don't block commit
|
|
||||||
|
|
||||||
### 5. Component exports check
|
|
||||||
Verify each component folder has a barrel export (`index.ts`) and the component has `displayName`:
|
|
||||||
```bash
|
|
||||||
# Check for missing index.ts
|
|
||||||
for dir in src/components/atoms/*/; do
|
|
||||||
[ -f "$dir/index.ts" ] || echo "Missing index.ts: $dir"
|
|
||||||
done
|
|
||||||
# Check for missing displayName (exclude stories)
|
|
||||||
find src/components/atoms/ -name "*.tsx" ! -name "*.stories.tsx" | xargs grep -L "displayName" 2>/dev/null
|
|
||||||
```
|
|
||||||
- **Pass:** All component folders have index.ts and components set displayName
|
|
||||||
- **Fail:** Missing exports or displayName
|
|
||||||
- **Critical:** No — warn but don't block commit
|
|
||||||
|
|
||||||
### 6. ESLint
|
|
||||||
```bash
|
|
||||||
npm run lint 2>&1
|
|
||||||
```
|
|
||||||
- **Pass:** No errors
|
|
||||||
- **Fail:** ESLint errors → report them
|
|
||||||
- **Fix:** Run `npm run lint:fix`
|
|
||||||
- **Critical:** Yes — do not commit if this fails
|
|
||||||
|
|
||||||
### 7. Prettier
|
|
||||||
```bash
|
|
||||||
npm run format:check 2>&1
|
|
||||||
```
|
|
||||||
- **Pass:** All files formatted correctly
|
|
||||||
- **Fail:** Formatting issues found → report them
|
|
||||||
- **Fix:** Run `npm run format`
|
|
||||||
- **Critical:** No — warn but don't block commit (Husky pre-commit hook auto-fixes these)
|
|
||||||
|
|
||||||
### Report format
|
|
||||||
```
|
|
||||||
PREFLIGHT RESULTS
|
|
||||||
═══════════════════════════════
|
|
||||||
✓ TypeScript .............. PASS
|
|
||||||
✓ Storybook build ........ PASS
|
|
||||||
✓ Token sync ............. PASS
|
|
||||||
⚠ Hardcoded values ....... WARN (2 issues)
|
|
||||||
✓ Component exports ...... PASS
|
|
||||||
✓ ESLint ................. PASS
|
|
||||||
✓ Prettier ............... PASS
|
|
||||||
───────────────────────────────
|
|
||||||
Result: PASS (safe to commit)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `PASS`, `FAIL`, or `WARN`. If any critical check fails, the result is `FAIL (do not commit)`. If only warnings, result is `PASS (safe to commit)` with warnings listed.
|
|
||||||
|
|
||||||
If `--fix` was passed, attempt to fix issues automatically (e.g., run `npm run build:tokens` for stale tokens) and re-check.
|
|
||||||
|
|
||||||
### 8. Visual QA spot-check (manual review, non-blocking)
|
|
||||||
|
|
||||||
If a component was recently modified, do a quick visual review of the source code for these common issues (adapted from impeccable /polish):
|
|
||||||
|
|
||||||
- **Transition consistency**: All interactive state changes should use `150ms ease-in-out` (FA convention). Flag mismatches.
|
|
||||||
- **Focus-visible**: Every interactive element should have a `:focus-visible` style. Flag any that rely only on hover.
|
|
||||||
- **Touch targets**: Interactive elements should have `minHeight >= 44px` for the largest size (or the mobile-intended size).
|
|
||||||
- **Spacing consistency**: Padding/gap values should use `theme.spacing()` or token CSS variables, not raw px.
|
|
||||||
|
|
||||||
These are advisory — report as `INFO` lines after the main results. Do not block commit for these.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Publish the project to all three targets: backup, dev remotes, and Chromatic.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### Step 1 — Pre-flight check
|
|
||||||
|
|
||||||
1. Ensure there are no uncommitted changes: `git status --porcelain`
|
|
||||||
- If dirty, ask the user if they want to commit first
|
|
||||||
2. Run `npx tsc --noEmit` to verify the build is clean
|
|
||||||
- If it fails, stop and report errors
|
|
||||||
|
|
||||||
### Step 2 — Push to backup (everything)
|
|
||||||
|
|
||||||
Push main directly to the `backup` remote (git.tensordesign.com.au/richie/ParsonsFA). This is a private repo that receives the full repo including AI tooling, memory, and working docs.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push backup main
|
|
||||||
```
|
|
||||||
|
|
||||||
If rejected (non-fast-forward), warn the user and ask before force-pushing.
|
|
||||||
|
|
||||||
### Step 3 — Push to dev remotes (stripped)
|
|
||||||
|
|
||||||
Both `fa-dev` and `sheffield` receive a stripped version with AI tooling removed. Use a temporary worktree to avoid touching the working directory.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clean up any prior worktree
|
|
||||||
git worktree remove --force /tmp/dev-push 2>/dev/null
|
|
||||||
git branch -D dev-clean 2>/dev/null
|
|
||||||
|
|
||||||
# Create worktree from current main
|
|
||||||
git worktree add /tmp/dev-push -b dev-clean
|
|
||||||
|
|
||||||
# In the worktree, remove AI tooling from the index
|
|
||||||
cd /tmp/dev-push
|
|
||||||
for path in .claude/ docs/memory/ docs/reference/; do
|
|
||||||
git rm -r --cached "$path" 2>/dev/null
|
|
||||||
done
|
|
||||||
git commit -m "Strip AI tooling and working docs for dev push"
|
|
||||||
|
|
||||||
# Push stripped branch to both dev remotes
|
|
||||||
cd <project-root>
|
|
||||||
git push fa-dev dev-clean:main --force
|
|
||||||
git push sheffield dev-clean:main --force
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
git worktree remove --force /tmp/dev-push
|
|
||||||
git branch -D dev-clean
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The `--force` is safe here because the stripped branch is always regenerated from main. Dev remotes never have unique commits.
|
|
||||||
|
|
||||||
### Step 4 — Sync assets to Gitea
|
|
||||||
|
|
||||||
Push the `brandassets/` directory to the dedicated assets repo so Chromatic can load images via external URLs.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp
|
|
||||||
rm -rf ParsonsAssets
|
|
||||||
git clone https://richie:151fdccacf11b6190d066a7e07f6f5310b2227dd@git.tensordesign.com.au/richie/ParsonsAssets.git
|
|
||||||
rsync -a --delete <project-root>/brandassets/ /tmp/ParsonsAssets/ --exclude='.git'
|
|
||||||
cd /tmp/ParsonsAssets
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet || git commit -m "Sync assets from main project"
|
|
||||||
git push origin main
|
|
||||||
cd <project-root>
|
|
||||||
rm -rf /tmp/ParsonsAssets
|
|
||||||
```
|
|
||||||
|
|
||||||
This step is idempotent — if nothing changed, no commit is created. Skip if you know no images were added or changed since last publish.
|
|
||||||
|
|
||||||
### Step 5 — Deploy to Chromatic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
STORYBOOK_ASSET_BASE=https://git.tensordesign.com.au/richie/ParsonsAssets/raw/branch/main npm run chromatic
|
|
||||||
```
|
|
||||||
|
|
||||||
The `STORYBOOK_ASSET_BASE` env var tells `assetUrl()` to resolve image paths from the Gitea assets repo instead of local static files. Run this in the background. Report the Storybook URL and build link when complete.
|
|
||||||
|
|
||||||
### Step 6 — Report
|
|
||||||
|
|
||||||
Summarise what was pushed:
|
|
||||||
|
|
||||||
```
|
|
||||||
Published to all targets:
|
|
||||||
- backup → git.tensordesign.com.au/richie/ParsonsFA (full)
|
|
||||||
- fa-dev → git.tensordesign.com.au/richie/Parsons (stripped)
|
|
||||||
- sheffield → sheffield.sapiente.casa/richie/ParsonsFA (stripped)
|
|
||||||
- assets → git.tensordesign.com.au/richie/ParsonsAssets (synced)
|
|
||||||
- Chromatic → [build link]
|
|
||||||
```
|
|
||||||
|
|
||||||
If any target failed, report which ones succeeded and which failed.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
---
|
|
||||||
name: quieter
|
|
||||||
description: Calmer design pass — reduces visual noise, aggressive colours, competing weights, and transactional copy. Highly FA-specific for grief-sensitive context.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to quiet]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Run a grief-sensitive design pass on a component or area, then **fix issues found**. This is assessment AND fix — diagnose anything that feels too loud, too urgent, or too transactional for a funeral planning context, then apply corrections.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA brand guidelines and tone
|
|
||||||
2. Read `docs/memory/decisions-log.md` for colour and tone decisions (especially D024 for error treatment)
|
|
||||||
3. Read the target component/area source files and stories
|
|
||||||
4. Reference `docs/reference/impeccable/color-and-contrast.md` for colour usage principles
|
|
||||||
5. Reference `docs/reference/impeccable/personas.md` for user archetypes (especially Jordan — confused first-timer, and Casey — distracted mobile user)
|
|
||||||
|
|
||||||
**FA context reminder**: "Understated empathy — warm but not gushy. Let UI structure be the empathy." Users are often elderly, emotional, and unfamiliar with technology. They may be planning a funeral for a loved one who has just died. Design must feel like a trusted advisor, not a sales platform. Every element that demands attention should earn it. Silence and space are design tools.
|
|
||||||
|
|
||||||
## Diagnostic Checks
|
|
||||||
|
|
||||||
Work through each check systematically. For each issue found, note the problem, then fix it.
|
|
||||||
|
|
||||||
### 1. Aggressive Colour Treatment
|
|
||||||
|
|
||||||
- **Error states must NOT use aggressive red** — FA uses a softer copper/warm treatment for errors (per D024)
|
|
||||||
- Check for bright, saturated reds (#ff0000, #e53935, MUI error.main defaults) in error messages, validation, or alerts
|
|
||||||
- Replace with the FA copper error palette from the theme (`palette.error.main` should already be the soft treatment)
|
|
||||||
- Warning states should also be warm, not harsh yellow/orange
|
|
||||||
- Success states should be muted green, not vivid/celebratory
|
|
||||||
- Check that semantic colours come from the theme, not hardcoded aggressive values
|
|
||||||
|
|
||||||
### 2. High Saturation Where Muted Would Be Better
|
|
||||||
|
|
||||||
- Scan for highly saturated colours that draw disproportionate attention
|
|
||||||
- Background colours should be muted — surfaces use the warm neutral palette, not bright fills
|
|
||||||
- Accent colours work because they are rare (60-30-10 rule) — if accent is used liberally, it stops working
|
|
||||||
- Icons and decorative elements should use muted tones unless they serve a functional purpose
|
|
||||||
- Check that the warm gold/copper brand palette is cohesive — no jarring colour outliers
|
|
||||||
|
|
||||||
### 3. Competing Visual Weights
|
|
||||||
|
|
||||||
- Look for multiple bold or heavy elements fighting for attention in the same view
|
|
||||||
- There should be one clear primary focal point — if multiple elements scream for attention, quiet the secondary ones
|
|
||||||
- Bold text should be used for emphasis, not as default — if everything is bold, nothing is
|
|
||||||
- Check for multiple large/heavy headings at the same level competing with each other
|
|
||||||
- Reduce weight on supporting elements (metadata, labels, secondary info) to let primary content breathe
|
|
||||||
|
|
||||||
### 4. Insufficient Whitespace
|
|
||||||
|
|
||||||
- Check padding and margins — grief-sensitive design needs generous breathing room
|
|
||||||
- Cards, sections, and content blocks should have ample internal padding
|
|
||||||
- Vertical spacing between sections should feel restful, not cramped
|
|
||||||
- Dense layouts feel overwhelming for users in emotional distress — space creates calm
|
|
||||||
- Check that spacing values come from the theme spacing scale, and lean toward the generous end
|
|
||||||
|
|
||||||
### 5. Urgent Animations and Transitions
|
|
||||||
|
|
||||||
- Animations should feel gentle, not snappy or urgent
|
|
||||||
- Check for bounce/elastic easing — replace with ease-out or ease-in-out
|
|
||||||
- Transition durations should be moderate (200-400ms for UI, longer for content reveals)
|
|
||||||
- No attention-grabbing animations (pulse, shake, flash) — these feel demanding
|
|
||||||
- Loading states should feel patient, not frantic
|
|
||||||
- If an animation can be removed without losing meaning, consider removing it
|
|
||||||
|
|
||||||
### 6. Transactional Copy
|
|
||||||
|
|
||||||
- Copy should feel empathetic and guiding, not sales-driven or transactional
|
|
||||||
- Check button labels: "Buy Now", "Add to Cart", "Submit" feel transactional — prefer "Continue", "Next Step", "Confirm Details"
|
|
||||||
- Check headings: avoid language that feels like a sales funnel ("Choose Your Package", "Upgrade", "Best Value")
|
|
||||||
- Error messages should be gentle and helpful, never blaming ("Something went wrong" not "You entered an invalid...")
|
|
||||||
- Avoid urgency language ("Limited time", "Act now", "Don't miss out") — this is deeply inappropriate in a grief context
|
|
||||||
- Pricing should be presented transparently, not with sales psychology (no crossed-out prices, no "savings" badges)
|
|
||||||
- Check for placeholder/lorem text that may have a casual or inappropriate tone
|
|
||||||
|
|
||||||
### 7. CTA Hierarchy
|
|
||||||
|
|
||||||
- There should be ONE clear primary CTA per view/section — not multiple competing calls to action
|
|
||||||
- Secondary actions should be visually distinct (outlined, text-only) not just smaller versions of primary buttons
|
|
||||||
- If there are more than 2 CTAs visible simultaneously, evaluate whether some can be demoted or removed
|
|
||||||
- The primary CTA should feel inviting, not pressuring — warm brand colour, not aggressive contrast
|
|
||||||
- Check that button hierarchy uses theme variants correctly (contained for primary, outlined for secondary, text for tertiary)
|
|
||||||
|
|
||||||
## Fix Process
|
|
||||||
|
|
||||||
For each issue found:
|
|
||||||
|
|
||||||
1. **Identify** the file, line, and current value
|
|
||||||
2. **Assess emotional impact** — how does this element feel to a grieving user?
|
|
||||||
3. **Determine the quieter alternative** — what change reduces noise while preserving function?
|
|
||||||
4. **Apply the fix** — update colours, weights, spacing, copy, or animation values
|
|
||||||
5. **Verify** the fix doesn't make the element invisible or non-functional — quiet does not mean hidden
|
|
||||||
|
|
||||||
## Report Format
|
|
||||||
|
|
||||||
After fixing, present a summary:
|
|
||||||
|
|
||||||
### Quiet Pass Summary
|
|
||||||
|
|
||||||
| Check | Status | Issues Found | Issues Fixed |
|
|
||||||
|-------|--------|-------------|-------------|
|
|
||||||
| Aggressive Colours | pass/warn/fail | N | N |
|
|
||||||
| High Saturation | pass/warn/fail | N | N |
|
|
||||||
| Competing Weights | pass/warn/fail | N | N |
|
|
||||||
| Whitespace | pass/warn/fail | N | N |
|
|
||||||
| Animations/Transitions | pass/warn/fail | N | N |
|
|
||||||
| Transactional Copy | pass/warn/fail | N | N |
|
|
||||||
| CTA Hierarchy | pass/warn/fail | N | N |
|
|
||||||
|
|
||||||
### Emotional Tone Assessment
|
|
||||||
|
|
||||||
Brief statement on the overall emotional register of the component:
|
|
||||||
- Does it feel like a **trusted advisor** or a **sales platform**?
|
|
||||||
- Would a grieving family member feel **supported** or **pressured**?
|
|
||||||
- Is the design **calm** or **demanding**?
|
|
||||||
|
|
||||||
### Changes Made
|
|
||||||
|
|
||||||
For each fix applied:
|
|
||||||
- **What changed**: Brief description
|
|
||||||
- **File**: Path and relevant line(s)
|
|
||||||
- **Before/After**: The old and new values
|
|
||||||
- **Why**: How this change serves the grief-sensitive context
|
|
||||||
|
|
||||||
### Remaining Concerns
|
|
||||||
|
|
||||||
Any issues that need design input, copy review, or are outside the scope of a quieting pass.
|
|
||||||
|
|
||||||
**NEVER**: Use aggressive red for error states. Add urgency language or sales psychology. Make everything equally loud. Remove functional affordances in the name of minimalism. Ignore that real people in real pain will use this interface.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
name: review-component
|
|
||||||
description: Review a component against design system conventions
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Review a component against FA Design System conventions and report pass/fail for each check.
|
|
||||||
|
|
||||||
**Component to review:** $ARGUMENTS
|
|
||||||
|
|
||||||
**Instructions:**
|
|
||||||
1. Read `docs/conventions/component-conventions.md` for the rules
|
|
||||||
2. Read `docs/conventions/token-conventions.md` for token usage rules
|
|
||||||
3. Read the component source file in `src/components/`
|
|
||||||
4. Read the component's Storybook stories
|
|
||||||
5. Reference `docs/reference/vercel/react-best-practices.md` for React performance patterns (optional, for deeper code quality review)
|
|
||||||
|
|
||||||
**Check each of these and report pass/fail:**
|
|
||||||
|
|
||||||
### Code quality
|
|
||||||
- [ ] Component uses TypeScript with proper types
|
|
||||||
- [ ] Props interface exported with JSDoc on every prop
|
|
||||||
- [ ] Uses `React.forwardRef` for interactive elements
|
|
||||||
- [ ] Accepts and forwards `sx` prop
|
|
||||||
- [ ] Uses `shouldForwardProp` for custom props on styled components
|
|
||||||
|
|
||||||
### Theme compliance
|
|
||||||
- [ ] NO hardcoded colours — all from `theme.palette.*`
|
|
||||||
- [ ] NO hardcoded spacing — all from `theme.spacing()`
|
|
||||||
- [ ] NO hardcoded typography — all from `theme.typography.*`
|
|
||||||
- [ ] NO hardcoded shadows — all from `theme.shadows`
|
|
||||||
- [ ] NO hardcoded border radius — all from `theme.shape.*`
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
- [ ] Minimum 44px touch target on mobile
|
|
||||||
- [ ] Visible focus indicator (focus-visible)
|
|
||||||
- [ ] Appropriate ARIA attributes
|
|
||||||
- [ ] Disabled state uses `aria-disabled`
|
|
||||||
- [ ] Colour contrast meets WCAG 2.1 AA
|
|
||||||
|
|
||||||
### Storybook coverage
|
|
||||||
- [ ] Default story
|
|
||||||
- [ ] All variants story
|
|
||||||
- [ ] All sizes story (if applicable)
|
|
||||||
- [ ] Disabled state
|
|
||||||
- [ ] Loading state (if applicable)
|
|
||||||
- [ ] Long content / overflow
|
|
||||||
- [ ] autodocs tag present
|
|
||||||
|
|
||||||
### Interactive states (ref: docs/reference/impeccable/interaction-design.md)
|
|
||||||
- [ ] Default (resting) state is styled
|
|
||||||
- [ ] Hover state provides visual feedback (not just cursor change)
|
|
||||||
- [ ] Focus-visible state is distinct from hover (keyboard users never see hover)
|
|
||||||
- [ ] Active/pressed state feels responsive
|
|
||||||
- [ ] Disabled state is visually diminished but still distinguishable
|
|
||||||
- [ ] Transitions use 150ms ease-in-out (FA convention)
|
|
||||||
|
|
||||||
### Design anti-patterns (ref: docs/reference/impeccable/frontend-design-skill.md)
|
|
||||||
- [ ] No grey text on coloured backgrounds (use a shade of the background colour instead)
|
|
||||||
- [ ] No cards nested inside cards (flatten hierarchy with spacing/typography)
|
|
||||||
- [ ] No identical card grids with zero variation (vary content, size, or emphasis)
|
|
||||||
- [ ] No bounce/elastic easing (use ease-out-quart or ease-in-out)
|
|
||||||
- [ ] Not every button is primary (use variant hierarchy: contained > soft > outlined > text)
|
|
||||||
- [ ] No redundant copy (headings don't restate content below them)
|
|
||||||
- [ ] No glassmorphism/blur used purely as decoration
|
|
||||||
- [ ] Whitespace is intentional, not leftover
|
|
||||||
|
|
||||||
**Report format:** List each check with pass/fail and specific issues found. End with a summary and recommended fixes.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
name: status
|
|
||||||
description: Report current status of tokens, components, and build health
|
|
||||||
---
|
|
||||||
|
|
||||||
Report the current status of the FA Design System.
|
|
||||||
|
|
||||||
**Instructions:**
|
|
||||||
1. Read `docs/memory/session-log.md` — summarise recent work
|
|
||||||
2. Read `docs/memory/component-registry.md` — count components by status (planned, in-progress, review, done)
|
|
||||||
3. Read `docs/memory/token-registry.md` — summarise token coverage
|
|
||||||
4. Read `docs/memory/decisions-log.md` — count decisions logged
|
|
||||||
5. Check if Storybook is running (curl http://localhost:6006)
|
|
||||||
6. Check if tokens build successfully (`npm run build:tokens`)
|
|
||||||
|
|
||||||
**Report format:**
|
|
||||||
```
|
|
||||||
## FA Design System Status
|
|
||||||
|
|
||||||
### Tokens
|
|
||||||
- Primitives: [count] defined
|
|
||||||
- Semantic: [count] defined
|
|
||||||
- Component: [count] defined
|
|
||||||
|
|
||||||
### Components
|
|
||||||
- Done: [list]
|
|
||||||
- In progress: [list]
|
|
||||||
- Planned: [list]
|
|
||||||
|
|
||||||
### Recent activity
|
|
||||||
- [last session summary]
|
|
||||||
|
|
||||||
### Next steps
|
|
||||||
- [recommended next actions]
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
name: sync-tokens
|
|
||||||
description: Rebuild CSS and JS outputs from token JSON sources
|
|
||||||
---
|
|
||||||
|
|
||||||
Synchronise design tokens — rebuild CSS and JS outputs from token JSON sources.
|
|
||||||
|
|
||||||
Use this after token JSON files have been edited manually or after `/create-tokens`. This is a maintenance command — it does NOT create new tokens (use `/create-tokens` for that).
|
|
||||||
|
|
||||||
Use the token-architect agent to handle this task.
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read `docs/memory/token-registry.md` to understand current token state
|
|
||||||
2. Validate all token JSON files have required fields (`$value`, `$type`, `$description`)
|
|
||||||
3. Run `npm run build:tokens` to regenerate:
|
|
||||||
- `src/theme/generated/tokens.css` (CSS custom properties)
|
|
||||||
- `src/theme/generated/tokens.js` (JS ES6 module)
|
|
||||||
- `tokens/export/tokens-flat.json` (flat JSON export)
|
|
||||||
4. Check that `src/theme/index.ts` is consuming the generated tokens correctly
|
|
||||||
5. If any tokens were added/changed since the theme was last updated, update `src/theme/index.ts`
|
|
||||||
6. Report what was generated and any issues found
|
|
||||||
7. Update `docs/memory/token-registry.md` if it's out of date
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
name: typeset
|
|
||||||
description: Typography refinement — checks hierarchy consistency, line length, line height, font weight, and mobile readability across a component or area.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[component or area to typeset]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Run a typography-focused review on a component or area, then **fix issues found**. This is assessment AND fix — diagnose problems, then apply corrections.
|
|
||||||
|
|
||||||
**Target:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/design-system.md` for FA typography conventions
|
|
||||||
2. Read `docs/memory/token-registry.md` for the current type scale and weight decisions
|
|
||||||
3. Read `docs/memory/decisions-log.md` for typography-related decisions (especially D017, D019)
|
|
||||||
4. Read the target component/area source files and stories
|
|
||||||
5. Reference `docs/reference/impeccable/typography.md` for detailed typographic guidelines
|
|
||||||
|
|
||||||
**FA context reminder**: Funeral Arranger serves families often in grief or distress. Many users are elderly, on mobile, and reading under emotional strain. Typography must prioritise readability and calm hierarchy over visual flair. Warmth comes through the serif display type (Noto Serif SC) at display sizes — not through decorative overuse. Body text must be effortlessly readable.
|
|
||||||
|
|
||||||
## Diagnostic Checks
|
|
||||||
|
|
||||||
Work through each check systematically. For each issue found, note the problem, then fix it.
|
|
||||||
|
|
||||||
### 1. Hierarchy Consistency
|
|
||||||
|
|
||||||
- Same semantic level must have the same visual treatment everywhere in the target
|
|
||||||
- Heading levels should use the correct MUI typography variant (h1-h6, subtitle1/2, body1/2)
|
|
||||||
- No more than 3-4 distinct text sizes visible at once (muddy hierarchy = too many similar sizes)
|
|
||||||
- Size jumps between levels should be clear — avoid sizes too close together (e.g., 14px, 15px, 16px)
|
|
||||||
- Check that the modular scale from the token system is followed consistently
|
|
||||||
|
|
||||||
### 2. Line Length (Measure)
|
|
||||||
|
|
||||||
- Body text should be constrained to 45-75 characters per line (`max-width: 65ch` is a good default)
|
|
||||||
- If body text runs full-width on desktop, add a `max-width` using `ch` units
|
|
||||||
- Narrow columns (sidebars, cards) may go below 45ch — tighten line-height to compensate
|
|
||||||
- Wide text blocks strain reading comprehension, especially for elderly/distressed users
|
|
||||||
|
|
||||||
### 3. Line Height
|
|
||||||
|
|
||||||
- Body text: 1.5-1.6 line-height for comfortable reading
|
|
||||||
- Headings: tighter line-height (1.1-1.3) since larger text needs less relative leading
|
|
||||||
- Line-height scales inversely with line length — narrow columns need tighter leading, wide columns need more
|
|
||||||
- Light text on dark backgrounds needs ~0.05-0.1 more line-height than normal
|
|
||||||
- Vertical rhythm: spacing values should relate to the base line-height unit
|
|
||||||
|
|
||||||
### 4. Font Weight Consistency
|
|
||||||
|
|
||||||
- **Body weight must be Medium 500** (per D019) — check that body text is not using 400 (Regular)
|
|
||||||
- Headings should use consistent weights within tiers (all h2s same weight, all h3s same weight)
|
|
||||||
- Avoid more than 3 font weights in the same view — too many competing weights create noise
|
|
||||||
- Bold should be used for emphasis, not as a default state for UI elements
|
|
||||||
- Check that weight values come from the theme, not hardcoded
|
|
||||||
|
|
||||||
### 5. Display Font Usage
|
|
||||||
|
|
||||||
- **Noto Serif SC is for display variants only** (per D017) — hero text, display headings, featured quotes
|
|
||||||
- Noto Serif SC must NOT be used for standard headings (h1-h6 in regular UI contexts)
|
|
||||||
- Standard headings use the sans-serif stack (Figtree or system font)
|
|
||||||
- If the serif font appears on a regular heading, replace it with the sans-serif variant
|
|
||||||
|
|
||||||
### 6. Mobile Font Size Adequacy
|
|
||||||
|
|
||||||
- Body text minimum 14px on mobile (16px preferred, per impeccable guidelines)
|
|
||||||
- Small/caption text minimum 12px
|
|
||||||
- Touch-target text (buttons, links in body) must be large enough to tap accurately
|
|
||||||
- Check that `rem` units are used (not `px`) so text respects browser zoom settings
|
|
||||||
- Verify no `user-scalable=no` in viewport meta (breaks accessibility)
|
|
||||||
|
|
||||||
### 7. Token Compliance
|
|
||||||
|
|
||||||
- All font sizes should come from theme.typography variants, not hardcoded values
|
|
||||||
- All font weights should come from theme tokens
|
|
||||||
- All line-heights should come from theme tokens
|
|
||||||
- Font family should come from theme, never hardcoded strings
|
|
||||||
- Check for inline `style` props that override typography tokens
|
|
||||||
|
|
||||||
## Fix Process
|
|
||||||
|
|
||||||
For each issue found:
|
|
||||||
|
|
||||||
1. **Identify** the file, line, and current value
|
|
||||||
2. **Reference** the correct token/theme value it should use
|
|
||||||
3. **Apply the fix** — update the component source to use the correct theme value
|
|
||||||
4. **Verify** the fix maintains visual intent (don't blindly replace — ensure the result looks right)
|
|
||||||
|
|
||||||
## Report Format
|
|
||||||
|
|
||||||
After fixing, present a summary:
|
|
||||||
|
|
||||||
### Typography Health Summary
|
|
||||||
|
|
||||||
| Check | Status | Issues Found | Issues Fixed |
|
|
||||||
|-------|--------|-------------|-------------|
|
|
||||||
| Hierarchy Consistency | pass/warn/fail | N | N |
|
|
||||||
| Line Length | pass/warn/fail | N | N |
|
|
||||||
| Line Height | pass/warn/fail | N | N |
|
|
||||||
| Font Weight | pass/warn/fail | N | N |
|
|
||||||
| Display Font Usage | pass/warn/fail | N | N |
|
|
||||||
| Mobile Font Size | pass/warn/fail | N | N |
|
|
||||||
| Token Compliance | pass/warn/fail | N | N |
|
|
||||||
|
|
||||||
### Changes Made
|
|
||||||
|
|
||||||
For each fix applied:
|
|
||||||
- **What changed**: Brief description
|
|
||||||
- **File**: Path and relevant line(s)
|
|
||||||
- **Before/After**: The old and new values
|
|
||||||
|
|
||||||
### Remaining Concerns
|
|
||||||
|
|
||||||
Any issues that need design input or are outside the scope of a typesetting pass.
|
|
||||||
|
|
||||||
**NEVER**: Hardcode font sizes in px. Use the serif display font (Noto Serif SC) for regular headings. Set body weight to 400 (Regular) — FA uses 500 (Medium). Remove typography tokens in favour of inline styles. Ignore mobile readability.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
name: write-stories
|
|
||||||
description: Write or update Storybook stories for a component
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Write or update Storybook stories for an existing component.
|
|
||||||
|
|
||||||
Use the story-writer agent to handle this task. The component to document:
|
|
||||||
|
|
||||||
**Component:** $ARGUMENTS
|
|
||||||
|
|
||||||
**Instructions for the agent:**
|
|
||||||
1. Read `docs/conventions/component-conventions.md` for story standards
|
|
||||||
2. Read the component source file at `src/components/` (check atoms/, molecules/, organisms/)
|
|
||||||
3. Create or update `{ComponentName}.stories.tsx` in the component's folder
|
|
||||||
4. Cover ALL items in the story coverage checklist:
|
|
||||||
- [ ] Default state with typical content
|
|
||||||
- [ ] All visual variants side by side
|
|
||||||
- [ ] All sizes side by side (if applicable)
|
|
||||||
- [ ] Disabled state
|
|
||||||
- [ ] Loading state (if applicable)
|
|
||||||
- [ ] Error state (if applicable)
|
|
||||||
- [ ] Long content / content overflow
|
|
||||||
- [ ] Empty/minimal content
|
|
||||||
- [ ] With and without optional elements (icons, badges, etc.)
|
|
||||||
5. Every story meta MUST include `tags: ['autodocs']`
|
|
||||||
6. Verify the component renders correctly in Storybook at http://localhost:6006
|
|
||||||
7. Update `docs/memory/session-log.md` when done
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
name: write-tests
|
|
||||||
description: Write or update tests for a component — determines whether it needs unit tests (Vitest), interaction tests (Storybook play), or both, then generates appropriate test code.
|
|
||||||
user-invocable: true
|
|
||||||
argument-hint: "[ComponentName]"
|
|
||||||
---
|
|
||||||
|
|
||||||
Write tests for the specified component.
|
|
||||||
|
|
||||||
**Component:** $ARGUMENTS
|
|
||||||
|
|
||||||
## Preparation
|
|
||||||
|
|
||||||
1. Read `docs/conventions/component-conventions.md` for component patterns
|
|
||||||
2. Read the component source file in `src/components/`
|
|
||||||
3. Read the component's existing Storybook stories
|
|
||||||
4. Check `docs/memory/component-registry.md` for component status and composition
|
|
||||||
|
|
||||||
## Determine Test Strategy
|
|
||||||
|
|
||||||
Categorise the component:
|
|
||||||
|
|
||||||
### Interactive components (need Storybook `play` functions)
|
|
||||||
Components with user interactions: clicks, toggles, keyboard navigation, form inputs, selection state changes.
|
|
||||||
|
|
||||||
**Examples:** Button, Input, SearchBar, ServiceOption, AddOnOption, Switch, Radio, FuneralFinder
|
|
||||||
|
|
||||||
For these, add `play` functions to existing stories:
|
|
||||||
```tsx
|
|
||||||
import { expect, userEvent, within } from '@storybook/test';
|
|
||||||
|
|
||||||
export const ClickTest: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
const button = canvas.getByRole('button');
|
|
||||||
await userEvent.click(button);
|
|
||||||
await expect(button).toBeVisible();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**What to test in `play` functions:**
|
|
||||||
- Click/tap fires expected callback
|
|
||||||
- Disabled state prevents interaction
|
|
||||||
- Keyboard navigation works (Enter, Space, Arrow keys)
|
|
||||||
- Loading state disables interaction
|
|
||||||
- Error states show correct feedback
|
|
||||||
- Selection state changes visually
|
|
||||||
- Form validation triggers on submit
|
|
||||||
|
|
||||||
### Logic-heavy components (need Vitest unit tests)
|
|
||||||
Components with significant internal logic: conditional rendering, validation, state machines, computed values.
|
|
||||||
|
|
||||||
**Examples:** FuneralFinder (validation logic), PackageDetail (price calculations), ServiceSelector (selection management)
|
|
||||||
|
|
||||||
Create `{ComponentName}.test.tsx` alongside the component:
|
|
||||||
```tsx
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import theme from '../../../theme';
|
|
||||||
import { ComponentName } from './ComponentName';
|
|
||||||
|
|
||||||
const renderWithTheme = (ui: React.ReactElement) =>
|
|
||||||
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
|
||||||
|
|
||||||
describe('ComponentName', () => {
|
|
||||||
it('renders with default props', () => {
|
|
||||||
renderWithTheme(<ComponentName />);
|
|
||||||
expect(screen.getByRole('...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**What to test in Vitest:**
|
|
||||||
- Conditional rendering logic (shows/hides elements based on props)
|
|
||||||
- Validation rules (required fields, format checks)
|
|
||||||
- Callback props fire with correct arguments
|
|
||||||
- Accessibility: correct ARIA roles and states
|
|
||||||
- Edge cases: empty data, maximum values, missing optional props
|
|
||||||
|
|
||||||
### Display-only components (minimal testing needed)
|
|
||||||
Components that only render static content from props: Typography, Badge, Divider, Card (non-interactive).
|
|
||||||
|
|
||||||
For these, stories ARE the tests. Ensure stories cover all variants. No additional test files needed unless there's conditional rendering logic.
|
|
||||||
|
|
||||||
## After Writing Tests
|
|
||||||
|
|
||||||
1. Run `npm run test` to verify Vitest tests pass
|
|
||||||
2. If Storybook `play` functions were added, verify they work in Storybook's test panel
|
|
||||||
3. Update `docs/memory/component-registry.md` with test status note
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Always wrap components in `ThemeProvider` with FA theme in Vitest tests
|
|
||||||
- Use `screen.getByRole()` over `getByTestId()` — test what the user sees
|
|
||||||
- Test behaviour, not implementation — don't test internal state directly
|
|
||||||
- Keep tests focused: one assertion per test where practical
|
|
||||||
- Don't test MUI internals — only test our component's API
|
|
||||||
- Don't snapshot test — snapshots are too brittle for an evolving design system
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-demo/
|
||||||
storybook-static/
|
storybook-static/
|
||||||
tokens/export/
|
tokens/export/
|
||||||
*.local
|
*.local
|
||||||
@@ -42,3 +43,6 @@ temp-db/
|
|||||||
|
|
||||||
# Root-level screenshots
|
# Root-level screenshots
|
||||||
/*.png
|
/*.png
|
||||||
|
|
||||||
|
# IDE-specific
|
||||||
|
*.code-workspace
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
BIN
brandassets/images/placeholder/hparsonsvenue.jpg
Normal file
BIN
brandassets/images/placeholder/hparsonsvenue.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -1,122 +0,0 @@
|
|||||||
# Component registry
|
|
||||||
|
|
||||||
Tracks the status, specification, and key details of every component in the
|
|
||||||
design system. Agents MUST check this before building a component (to avoid
|
|
||||||
duplicates) and MUST update it after completing one.
|
|
||||||
|
|
||||||
## Status definitions
|
|
||||||
|
|
||||||
- **planned**: Component is identified but not yet started
|
|
||||||
- **in-progress**: Component is being built
|
|
||||||
- **review**: Component is built, awaiting human review
|
|
||||||
- **done**: Component is reviewed and approved
|
|
||||||
- **needs-revision**: Component needs changes based on review feedback
|
|
||||||
|
|
||||||
## Atoms
|
|
||||||
|
|
||||||
| Component | Status | Variants | Tokens used | Notes |
|
|
||||||
|-----------|--------|----------|-------------|-------|
|
|
||||||
| Button | done | contained, soft, outlined, text × xs, small, medium, large × primary, secondary + loading, underline, fullWidth | button.height/paddingX/paddingY/fontSize/iconSize/iconGap/borderRadius, color.interactive.*, color.brand.100-300, color.neutral.200-700 | Primary interactive element. Merges Text Button from Figma. Soft variant = Figma's Secondary/Brand & Secondary/Grey. |
|
|
||||||
| IconButton | done | default, primary, secondary, error × small, medium, large | Reuses button.height/iconSize tokens, color.interactive.*, color.neutral.* | Icon-only button (close, menu, actions). Wraps MUI IconButton. Rounded rect, brand hover, focus ring. |
|
|
||||||
| Typography | done | displayHero, display1-3, displaySm, h1-h6, bodyLg, body1, body2, bodyXs, labelLg, label, labelSm, caption, captionSm, overline, overlineSm + maxLines, gutterBottom | typography.* (all semantic typography tokens), fontFamily.body, fontFamily.display | Text display system. Thin MUI wrapper with maxLines truncation. |
|
|
||||||
| Input | done | medium, small × default, hover, focus, error, success, disabled + startIcon, endIcon, required, multiline | input.height/paddingX/paddingY/fontSize/borderRadius/gap/iconSize, color.neutral.300-400, color.brand.500, color.feedback.error/success, color.text.secondary | External label pattern, branded focus ring, two sizes aligned with Button. Adds startIcon/endIcon and success state beyond Figma. |
|
|
||||||
| Badge | done | soft, filled × default, brand, success, warning, error, info × small, medium + icon | badge.height/paddingX/fontSize/iconSize/iconGap/borderRadius, color.feedback.*, color.brand.200/700 | Status indicator pill. Soft (tonal) or filled (solid). 6 colours, 2 sizes, optional leading icon. |
|
|
||||||
| Icon | planned | various sizes | | Icon wrapper component |
|
|
||||||
| Avatar | planned | image, initials, icon × small, medium, large | | User/entity representation |
|
|
||||||
| Divider | done | horizontal, vertical × fullWidth, inset, middle + text, flexItem | color.border.default (via palette.divider) | Visual separator. Wraps MUI Divider. Supports text children and orientation. |
|
|
||||||
| Chip | done | filled, outlined × small, medium × clickable, deletable, selected × default, primary | chip.height/paddingX/fontSize/iconSize/deleteIconSize/iconGap/borderRadius, color.neutral.200-700, color.brand.200-700 | Interactive tag. Wraps MUI Chip with FA tokens. Selected state promotes to brand colour. Filled uses soft tonal bg (like Badge). |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. |
|
|
||||||
| DialogShell | done | open/closed × with/without back button × with/without footer | (theme defaults — borderRadius, palette) | Standard dialog container. Header (title + optional back + close), divider, scrollable body, optional footer. Used by FilterPanel and ArrangementDialog. |
|
|
||||||
| ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. |
|
|
||||||
|
|
||||||
## Molecules
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| 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. |
|
|
||||||
| MapCard | planned | Card + Typography + Badge | Compact horizontal map popup card. Deferred until map integration. |
|
|
||||||
| ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. |
|
|
||||||
| SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. |
|
|
||||||
| AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). |
|
|
||||||
| StepIndicator | done | Typography + Box | Horizontal segmented progress bar. Brand gold for completed/current steps, grey for upcoming. Responsive bar height (10px/6px). Maps to Figma Progress Bar - Steps (2375:47468). |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| ComparisonPackageCard | done | Card + Badge + Button + Divider + Typography + Tooltip + LocationOnOutlinedIcon + VerifiedOutlinedIcon + CheckCircleOutlineIcon + InfoOutlinedIcon | Mobile full-width package card for ComparisonPage tabpanels. Provider header (verified badge, name, location, rating, package name, price, CTA) + itemised sections with left-accent headings. Shadow (shadow-sm). Medium button. Reuses `ComparisonPackage` type from ComparisonTable. Shared by ComparisonPage V2 and V1 (extracted 2026-04-09). |
|
|
||||||
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating verified badge, recommended banner, provider name (truncated+tooltip), location, rating, package name, price, CTA, optional Remove link. Extracted from ComparisonTable (2026-04-12). |
|
|
||||||
| ComparisonTabCard | done | Card + Badge + Typography | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap + brand glow. Fixed 210px width. Shared by V1 and V2 (extracted 2026-04-12). |
|
|
||||||
|
|
||||||
## Organisms
|
|
||||||
|
|
||||||
| Component | Status | Composed of | Notes |
|
|
||||||
|-----------|--------|-------------|-------|
|
|
||||||
| 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 |
|
|
||||||
| 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 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 V4 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepIndicator/FieldError | Archived. Based on V2 with: 3 numbered steps (48px circles, outline-to-fill + tick), ungated location field, no heading/subheading, "Search" CTA, inline copper error messages. |
|
|
||||||
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
|
||||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
|
||||||
| Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
|
||||||
|
|
||||||
## Templates
|
|
||||||
|
|
||||||
| Component | Status | Composed of | Notes |
|
|
||||||
|-----------|--------|-------------|-------|
|
|
||||||
| WizardLayout | done | Container + Box + Link + Typography + Navigation (slot) + StepIndicator (slot) | Page-level layout for arrangement wizard. 5 variants: centered-form, list-map, list-detail, grid-sidebar, detail-toggles. Nav slot, sticky help bar, optional back link, optional progress stepper + running total. `<main>` landmark wrapper. |
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
| Component | Status | Composed of | Notes |
|
|
||||||
|-----------|--------|-------------|-------|
|
|
||||||
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
|
||||||
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
|
||||||
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
|
|
||||||
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
|
||||||
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
|
|
||||||
| DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. |
|
|
||||||
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7a — venue browsing. Click-to-navigate card grid with search/filters. Leads to VenueDetailStep. |
|
|
||||||
| VenueDetailStep | done | WizardLayout (detail-toggles) + ImageGallery + Card + Chip + Typography + Button + Divider | Wizard step 7b — venue detail. Two-panel: gallery/description/features/location (left), name/meta/price/CTA/religions (right). Informational service preview. |
|
|
||||||
| VenueServicesStep | done | WizardLayout (centered-form) + AddOnOption + Card + Typography + Button + Divider | Wizard step 7c — venue services. Compact venue card, availability notices, AddOnOption toggles with "View more" for long descriptions. Follows VenueDetailStep. |
|
|
||||||
| CrematoriumStep | done | WizardLayout (centered-form) + Card + Badge + ToggleButtonGroup + Typography + Button + Divider | Wizard step 8 — crematorium. Two variants: Service & Cremation (compact card + witness Yes/No toggle), Cremation Only (compact card + "Cremation Only" badge + "Included in Package" notice). Single pre-selected crematorium, no multi-select. |
|
|
||||||
| CemeteryStep | done | WizardLayout (centered-form) + ToggleButtonGroup + Collapse + TextField (select) + Typography + Button + Divider | Wizard step 9 — cemetery. ToggleButtonGroups (Yes/No/Not sure) with progressive disclosure. Own plot → locate dropdown. No plot → preference? → select dropdown. No card grid. |
|
|
||||||
| CoffinsStep | done | WizardLayout (grid-sidebar) + Card + Badge + Collapse + Slider + TextField + Pagination + Divider + Link | Wizard step 10 — coffin browsing. Grid-sidebar: filter sidebar (categories with expandable subcategories, dual-knob price slider with editable inputs, sort by) + 3-col card grid. CoffinCard with thumbnail hover preview. Equal-height cards, subtle bg for white-bg product photos. Card click → CoffinDetailsStep (no Continue). 20/page max. Conditional allowance info bubble. |
|
|
||||||
| CoffinDetailsStep | done | WizardLayout (detail-toggles) + ImageGallery + Divider + Button | Wizard step 11 — coffin detail. Two-panel: gallery + product details dl (left), name + description + colour swatches + allowance-aware price + CTA (right). Allowance logic: fully covered / partially covered / no allowance. Colour selection does not affect price. |
|
|
||||||
| ~~AdditionalServicesStep~~ | removed | — | Replaced by IncludedServicesStep + ExtrasStep. Split for clearer distinction between free inclusions and paid extras. |
|
|
||||||
| IncludedServicesStep | done | WizardLayout (centered-form) + AddOnOption + RadioGroup + Collapse + Divider + Button | Wizard step 12a — included services. Package inclusions at no additional cost: dressing, viewing (with same-venue sub-option), prayers/vigil, funeral announcement. Sub-options render inside parent card. |
|
|
||||||
| ExtrasStep | done | WizardLayout (centered-form) + AddOnOption + Card + Switch + RadioGroup + Collapse + Divider + Button | Wizard step 12b — optional extras. Lead-gen interest capture: catering, music (inline live musician toggle + musician type), coffin bearing (toggle + bearer type), newspaper notice. POA via `priceLabel`. Tally of priced selections. No nested cards. |
|
|
||||||
| SummaryStep | done | WizardLayout (centered-form) + Card + Paper + DialogShell + Button + Link + Divider | Wizard step 13 — plan review. Visual cart layout: arrangement details (2-col grid), compact cards with thumbnails for provider/venue/crematorium/coffin, checklist for included services, priced list for extras. Allowance display (fully covered vs remaining). Share dialog (multi-email). Location pin icons. Full-width CTA. |
|
|
||||||
| PaymentStep | done | WizardLayout (centered-form) + ToggleButtonGroup + Paper + Collapse + Checkbox + Divider + Button | Wizard step 14 — payment. Plan (full/deposit) + method (card/bank). PayWay iframe slot. Bank transfer details. Terms checkbox. |
|
|
||||||
| 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. |
|
|
||||||
| 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 (V2) | done | WizardLayout (wide-form) + ComparisonTable + Card + Typography + Button + Divider + StarRoundedIcon | **Production version.** Package comparison page. Desktop: full ComparisonTable with sticky headers, **recommended package as first (leftmost) column**. Mobile: tabbed card view with horizontal tab rail (role="tablist") + single package card (role="tabpanel"); **recommended tab is first in rail, first user-selected package is initially active**. Recommended package as separate prop (D038). Star icon (brand-600) marks recommended in mobile tab labels. Share + Print in page header. Back link, help bar. |
|
|
||||||
| ComparisonPage V1 | archived | WizardLayout + ComparisonTable + Card + Typography + Button + Divider | Archived — viewable in Storybook under Archive/. Recommended package as **last** column/tab. Same component tree as V2. |
|
|
||||||
|
|
||||||
## Future enhancements
|
|
||||||
|
|
||||||
Deferred items that should be addressed when the relevant components or patterns
|
|
||||||
are needed. Check this section before building new components — an item here may
|
|
||||||
be relevant to your current work.
|
|
||||||
|
|
||||||
| Item | Relates to | Trigger | Notes |
|
|
||||||
|------|-----------|---------|-------|
|
|
||||||
| Destructive button colours | Button | When building delete/cancel flows | `color="error"` already works via MUI palette. May need `soft` variant styling for error/warning/success colours. |
|
|
||||||
| Link-as-button | Button | When building Navigation or link-heavy pages | Use MUI's `component="a"` or `href` prop. May warrant a separate Link atom or a `Button` story showing the pattern. |
|
|
||||||
| ~~IconButton atom~~ | ~~IconButton~~ | ~~Resolved~~ | ~~Built as atom. Rounded rect, 3 sizes, 4 colours, focus ring.~~ |
|
|
||||||
| ~~Google Fonts loading~~ | ~~Typography~~ | ~~Resolved~~ | ~~Added to .storybook/preview-head.html and index.html~~ |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
|||||||
# Component Lifecycle
|
|
||||||
|
|
||||||
Every component follows this lifecycle. Skills are run in order — each stage must
|
|
||||||
pass before moving to the next. This prevents ad-hoc back-and-forth tweaking.
|
|
||||||
|
|
||||||
## The Stages
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 1. BUILD /build-atom, /build-molecule, /build-organism │
|
|
||||||
│ 2. STORIES /write-stories │
|
|
||||||
│ 3. INTERNAL QA /audit → /critique → /harden │
|
|
||||||
│ 4. FIX Fix all P0 and P1 issues from stage 3 │
|
|
||||||
│ 5. POLISH /polish → /typeset → /adapt │
|
|
||||||
│ 6. PRESENT Show to user in Storybook │
|
|
||||||
│ 7. ITERATE User feedback → targeted fixes (1-2 rounds) │
|
|
||||||
│ 8. NORMALIZE /normalize (cross-component consistency) │
|
|
||||||
│ 9. PREFLIGHT /preflight │
|
|
||||||
│ 10. COMMIT git add → commit → push │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to use each skill
|
|
||||||
|
|
||||||
### Stage 1 — BUILD
|
|
||||||
**Skill:** `/build-atom`, `/build-molecule`, `/build-organism`
|
|
||||||
**When:** Starting a new component. The skill handles reading memory files,
|
|
||||||
checking the registry, creating the file structure, and writing the code.
|
|
||||||
**Output:** Component .tsx + stories .tsx + index.ts
|
|
||||||
|
|
||||||
### Stage 2 — STORIES
|
|
||||||
**Skill:** `/write-stories`
|
|
||||||
**When:** If the build skill didn't produce comprehensive stories, or if stories
|
|
||||||
need updating after changes. Stories must cover: default, all variants, all
|
|
||||||
sizes, disabled, loading, error, long content, minimal content.
|
|
||||||
**Output:** Complete story coverage in Storybook
|
|
||||||
|
|
||||||
### Stage 3 — INTERNAL QA (run before showing to user)
|
|
||||||
Three skills, run in this order:
|
|
||||||
|
|
||||||
1. **`/audit`** — Technical quality (a11y, performance, theming, responsive, design).
|
|
||||||
Produces a score out of 20 and P0-P3 issues.
|
|
||||||
2. **`/critique`** — UX design review (hierarchy, emotion, cognitive load, composition).
|
|
||||||
Produces a score out of 40 and priority issues.
|
|
||||||
3. **`/harden`** — Edge cases (error states, empty states, loading, boundaries, disabled).
|
|
||||||
Ensures robustness for real-world data.
|
|
||||||
|
|
||||||
**Exit criteria:** No P0 issues remaining. P1 issues documented.
|
|
||||||
|
|
||||||
### Stage 4 — FIX
|
|
||||||
**No skill — just implementation work.**
|
|
||||||
**When:** Fix all P0 and P1 issues found in stage 3.
|
|
||||||
Then re-run the relevant check (e.g., if the fix was an a11y issue, re-run
|
|
||||||
`/audit` to verify). Don't re-run all three unless the fixes were broad.
|
|
||||||
**Exit criteria:** P0 = 0, P1 = 0 (or documented as intentional with rationale).
|
|
||||||
|
|
||||||
### Stage 5 — POLISH
|
|
||||||
Three skills, run as needed based on the component:
|
|
||||||
|
|
||||||
1. **`/polish`** — Visual alignment, spacing, transitions, copy, micro-details.
|
|
||||||
Run on every component.
|
|
||||||
2. **`/typeset`** — Typography: hierarchy, line length, weight, readability.
|
|
||||||
Run on text-heavy components (cards, forms, detail panels).
|
|
||||||
3. **`/adapt`** — Responsive: touch targets, overflow, mobile spacing.
|
|
||||||
Run on layout components (organisms, cards, navigation).
|
|
||||||
|
|
||||||
**Optional context-specific skills:**
|
|
||||||
- **`/quieter`** — Run on components that handle sensitive moments (pricing,
|
|
||||||
commitment steps, error messaging). Not needed for utility atoms.
|
|
||||||
- **`/clarify`** — Run on components with decision points or complex information
|
|
||||||
(FuneralFinder, ArrangementForm, PricingTable). Not needed for simple atoms.
|
|
||||||
|
|
||||||
### Stage 6 — PRESENT
|
|
||||||
**No skill — show in Storybook.**
|
|
||||||
**When:** All internal QA is done. The component should be in its best state
|
|
||||||
before the user sees it. Present with a brief summary of what it does, key
|
|
||||||
design decisions, and scores from audit/critique.
|
|
||||||
|
|
||||||
### Stage 7 — ITERATE
|
|
||||||
**No skill — targeted fixes from user feedback.**
|
|
||||||
**When:** User reviews in Storybook and gives feedback. This should be 1-2 rounds
|
|
||||||
max because stages 3-5 caught most issues. If feedback requires major changes,
|
|
||||||
go back to stage 1. Minor tweaks stay here.
|
|
||||||
**Exit criteria:** User approves.
|
|
||||||
|
|
||||||
### Stage 8 — NORMALIZE
|
|
||||||
**Skill:** `/normalize`
|
|
||||||
**When:** After user approval, run against the component's tier (e.g., `/normalize atoms`)
|
|
||||||
to check it's consistent with its peers. This catches: token access patterns (D031),
|
|
||||||
transition timing, focus styles, spacing methods, displayName, exports.
|
|
||||||
**Note:** This is a cross-component check, so it's most valuable after several
|
|
||||||
components in a tier are done. Can be batched.
|
|
||||||
|
|
||||||
### Stage 9 — PREFLIGHT
|
|
||||||
**Skill:** `/preflight`
|
|
||||||
**When:** Before committing. Verifies TypeScript, Storybook build, token sync,
|
|
||||||
hardcoded values, exports, ESLint, Prettier.
|
|
||||||
**Exit criteria:** All critical checks pass.
|
|
||||||
|
|
||||||
### Stage 10 — COMMIT
|
|
||||||
**No skill — git workflow.**
|
|
||||||
Stage, commit with descriptive message, push. Husky runs lint-staged automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shorthand for quick reference
|
|
||||||
|
|
||||||
| Stage | Skill(s) | Who triggers | Blocking? |
|
|
||||||
|-------|----------|-------------|-----------|
|
|
||||||
| Build | /build-{tier} | User requests | — |
|
|
||||||
| Stories | /write-stories | Auto in build | — |
|
|
||||||
| Internal QA | /audit → /critique → /harden | Agent (auto) | P0 = blocking |
|
|
||||||
| Fix | — | Agent | Until P0/P1 = 0 |
|
|
||||||
| Polish | /polish + /typeset + /adapt | Agent (auto) | — |
|
|
||||||
| Present | — | Agent → User | — |
|
|
||||||
| Iterate | — | User feedback | 1-2 rounds |
|
|
||||||
| Normalize | /normalize | Agent (batch OK) | — |
|
|
||||||
| Preflight | /preflight | Agent (auto) | Critical = blocking |
|
|
||||||
| Commit | — | Agent | — |
|
|
||||||
|
|
||||||
**"Agent (auto)"** means I should run these proactively without being asked.
|
|
||||||
**"Agent (batch OK)"** means it can be deferred and run across multiple components.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Which skills are optional vs required?
|
|
||||||
|
|
||||||
| Skill | Required for | Optional for |
|
|
||||||
|-------|-------------|-------------|
|
|
||||||
| /audit | All components | — |
|
|
||||||
| /critique | All molecules + organisms | Simple atoms (Button, Divider) |
|
|
||||||
| /harden | All interactive components | Display-only atoms (Typography, Badge) |
|
|
||||||
| /polish | All components | — |
|
|
||||||
| /typeset | Text-heavy components | Icon-only or structural components |
|
|
||||||
| /adapt | Layout components, organisms | Small inline atoms |
|
|
||||||
| /quieter | Sensitive context components | Utility atoms |
|
|
||||||
| /clarify | Decision-point components | Simple atoms |
|
|
||||||
| /normalize | All (batched by tier) | — |
|
|
||||||
| /preflight | All (before commit) | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## For existing components
|
|
||||||
|
|
||||||
Components built before this lifecycle was defined can be retroactively
|
|
||||||
reviewed using a condensed process:
|
|
||||||
|
|
||||||
1. `/normalize {tier}` — Scan the tier for consistency issues
|
|
||||||
2. `/audit {component}` — Score each component
|
|
||||||
3. Fix P0/P1 issues only (don't re-polish what's already working)
|
|
||||||
4. `/preflight` → commit
|
|
||||||
|
|
||||||
This is lighter than the full lifecycle because these components have already
|
|
||||||
been through user review and iteration.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# FuneralFinder — Flow Logic Reference
|
|
||||||
|
|
||||||
Technical reference for the FuneralFinder stepped search widget.
|
|
||||||
Use this when modifying the flow, adding steps, or integrating with a backend.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
The widget is a **single React component** with internal state. No external state
|
|
||||||
management required. The parent only needs to provide `funeralTypes`, optional
|
|
||||||
`themeOptions`, and an `onSearch` callback.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Header (h2 display + subheading) │
|
|
||||||
│ ───────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ CompletedRows (stack of answered steps)│
|
|
||||||
│ │
|
|
||||||
│ Active Step (one at a time, Collapse) │
|
|
||||||
│ Step 1 │ Step 2 │ Step 3 │ Step 4 │
|
|
||||||
│ │
|
|
||||||
│ ─── always visible ─────────────────── │
|
|
||||||
│ Location input │
|
|
||||||
│ [Find funeral providers] CTA │
|
|
||||||
│ Free to use · No obligation │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## State
|
|
||||||
|
|
||||||
| State variable | Type | Default | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `intent` | `'arrange' \| 'preplan' \| null` | `null` | Step 1 answer |
|
|
||||||
| `planningFor` | `'myself' \| 'someone-else' \| null` | `null` | Step 2 answer (preplan only) |
|
|
||||||
| `typeSelection` | `string \| null` | `null` | Step 3 answer — funeral type ID or `'all'` |
|
|
||||||
| `servicePref` | `'with-service' \| 'without-service' \| 'either'` | `'either'` | Step 4 answer |
|
|
||||||
| `serviceAnswered` | `boolean` | `false` | Whether step 4 was explicitly answered |
|
|
||||||
| `selectedThemes` | `string[]` | `[]` | Optional theme filter IDs (multi-select) |
|
|
||||||
| `location` | `string` | `''` | Location input value |
|
|
||||||
| `locationError` | `string` | `''` | Validation error for location |
|
|
||||||
| `showIntentPrompt` | `boolean` | `false` | Show nudge when CTA clicked without intent |
|
|
||||||
| `editingStep` | `number \| null` | `null` | Which step is being re-edited (via "Change") |
|
|
||||||
|
|
||||||
## Step Flow
|
|
||||||
|
|
||||||
### Active Step Calculation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const activeStep = (() => {
|
|
||||||
if (editingStep !== null) return editingStep; // User clicked "Change"
|
|
||||||
if (!intent) return 1; // Need intent
|
|
||||||
if (needsPlanningFor && !planningFor) return 2; // Need planning-for (preplan only)
|
|
||||||
if (!typeSelection) return 3; // Need funeral type
|
|
||||||
if (showServiceStep && !serviceAnswered) return 4; // Need service pref
|
|
||||||
return 0; // All complete
|
|
||||||
})();
|
|
||||||
```
|
|
||||||
|
|
||||||
`activeStep === 0` means all optional steps are answered. Only CompletedRows +
|
|
||||||
location + CTA are visible.
|
|
||||||
|
|
||||||
### Step Details
|
|
||||||
|
|
||||||
| Step | Question | Options | Auto-advances? | Conditional? |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | How can we help you today? | Arrange now / Pre-plan | Yes, on click | Always shown |
|
|
||||||
| 2 | Who are you planning for? | Myself / Someone else | Yes, on click | Only when `intent === 'preplan'` |
|
|
||||||
| 3 | What type of funeral? | TypeCards + Explore All + theme chips | Yes, on type card click | Always shown |
|
|
||||||
| 4 | Would you like a service? | With / No / Flexible (chips) | Yes, on chip click | Only when selected type has `hasServiceOption: true` |
|
|
||||||
|
|
||||||
### Auto-advance Mechanic
|
|
||||||
|
|
||||||
Steps 1, 2, and 4 auto-advance because selecting an option sets the state and
|
|
||||||
clears `editingStep`. The `activeStep` recalculation on the next render
|
|
||||||
determines the new step.
|
|
||||||
|
|
||||||
Step 3 also auto-advances when a type card is clicked. Theme preferences within
|
|
||||||
step 3 are optional — they're captured at whatever state they're in when the
|
|
||||||
type card click triggers collapse.
|
|
||||||
|
|
||||||
### Editing (reverting to a previous step)
|
|
||||||
|
|
||||||
Clicking "Change" on a CompletedRow calls `revertTo(stepNumber)`, which sets
|
|
||||||
`editingStep`. This overrides the `activeStep` calculation, reopening that step.
|
|
||||||
When the user makes a new selection, the handler clears `editingStep` and the
|
|
||||||
flow recalculates.
|
|
||||||
|
|
||||||
**Key behaviour:** Editing a step does NOT reset downstream answers. If you
|
|
||||||
change from Cremation to Burial (both have `hasServiceOption`), the service
|
|
||||||
preference carries forward. If you change to a type without `hasServiceOption`
|
|
||||||
(or to "Explore all"), `servicePref` resets to `'either'` and `serviceAnswered`
|
|
||||||
resets to `false`.
|
|
||||||
|
|
||||||
## CTA and Search Logic
|
|
||||||
|
|
||||||
### Minimum Requirements
|
|
||||||
|
|
||||||
The CTA button is **always visible and always enabled** (except during loading).
|
|
||||||
Minimum search requirements: **intent + location (3+ chars)**.
|
|
||||||
|
|
||||||
### Submit Behaviour
|
|
||||||
|
|
||||||
```
|
|
||||||
User clicks "Find funeral providers"
|
|
||||||
│
|
|
||||||
├─ intent is null?
|
|
||||||
│ → Show intent prompt (role="alert"), keep step 1 visible
|
|
||||||
│ → Return (don't search)
|
|
||||||
│
|
|
||||||
├─ location < 3 chars?
|
|
||||||
│ → Show error on location input
|
|
||||||
│ → Return (don't search)
|
|
||||||
│
|
|
||||||
└─ Both present?
|
|
||||||
→ Call onSearch() with smart defaults for missing optional fields
|
|
||||||
```
|
|
||||||
|
|
||||||
### Smart Defaults
|
|
||||||
|
|
||||||
| Field | If not explicitly answered | Default value |
|
|
||||||
|---|---|---|
|
|
||||||
| `funeralTypeId` | User didn't select a type | `null` (= show all types) |
|
|
||||||
| `servicePreference` | User didn't answer service step | `'either'` (= show all) |
|
|
||||||
| `themes` | User didn't select any themes | `[]` (= no filter) |
|
|
||||||
| `planningFor` | User on preplan path but didn't answer step 2 | `undefined` |
|
|
||||||
|
|
||||||
This means a user can: select intent → type location → click CTA. Everything
|
|
||||||
else defaults to "show all."
|
|
||||||
|
|
||||||
### Search Params Shape
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface FuneralSearchParams {
|
|
||||||
intent: 'arrange' | 'preplan';
|
|
||||||
planningFor?: 'myself' | 'someone-else'; // Only on preplan path
|
|
||||||
funeralTypeId: string | null; // null = all types
|
|
||||||
servicePreference: 'with-service' | 'without-service' | 'either';
|
|
||||||
themes: string[]; // May be empty
|
|
||||||
location: string; // Trimmed, 3+ chars
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conditional Logic Map
|
|
||||||
|
|
||||||
```
|
|
||||||
intent === 'preplan'
|
|
||||||
└─ Shows step 2 (planning-for)
|
|
||||||
|
|
||||||
typeSelection !== 'all' && selectedType.hasServiceOption === true
|
|
||||||
└─ Shows step 4 (service preference)
|
|
||||||
|
|
||||||
typeSelection !== null
|
|
||||||
└─ CompletedRow for type shows (with theme summary if any selected)
|
|
||||||
|
|
||||||
serviceAnswered && showServiceStep
|
|
||||||
└─ CompletedRow for service shows
|
|
||||||
|
|
||||||
themeOptions.length > 0
|
|
||||||
└─ Theme chips appear within step 3 (always, not gated by type selection)
|
|
||||||
|
|
||||||
loading === true
|
|
||||||
└─ CTA button shows spinner, button disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
## Props Reference
|
|
||||||
|
|
||||||
| Prop | Type | Default | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `funeralTypes` | `FuneralTypeOption[]` | required | Each has `id`, `label`, optional `description`, `note`, `hasServiceOption` |
|
|
||||||
| `themeOptions` | `ThemeOption[]` | `[]` | Each has `id`, `label`. Shown as optional chips in step 3 |
|
|
||||||
| `onSearch` | `(params: FuneralSearchParams) => void` | — | Called on valid submit |
|
|
||||||
| `loading` | `boolean` | `false` | Shows spinner on CTA, disables button |
|
|
||||||
| `heading` | `string` | `'Find funeral directors near you'` | Main h2 heading |
|
|
||||||
| `subheading` | `string` | `'Tell us a little about...'` | Below heading |
|
|
||||||
| `showExploreAll` | `boolean` | `true` | Show "Explore all options" TypeCard |
|
|
||||||
| `sx` | `SxProps<Theme>` | — | MUI sx override on root card |
|
|
||||||
|
|
||||||
## Sub-components (internal)
|
|
||||||
|
|
||||||
| Component | Purpose | Used in |
|
|
||||||
|---|---|---|
|
|
||||||
| `StepHeading` | Centered bodyLg heading with bottom margin | Steps 1-4 |
|
|
||||||
| `ChoiceCard` | Full-width radio card with label + description | Steps 1, 2 |
|
|
||||||
| `TypeCard` | Compact radio card with label + optional description/note | Step 3 |
|
|
||||||
| `CompletedRow` | Summary row: question + bold answer + "Change" link | All completed steps |
|
|
||||||
|
|
||||||
## Adding a New Step
|
|
||||||
|
|
||||||
1. Add state variable(s) for the new step's answer
|
|
||||||
2. Add a condition in `activeStep` calculation (between existing steps)
|
|
||||||
3. Add a `<Collapse in={activeStep === N}>` block in the render
|
|
||||||
4. Add a `<Collapse>` for the CompletedRow (with appropriate visibility condition)
|
|
||||||
5. Include the new data in `handleSubmit` → `onSearch()` params
|
|
||||||
6. Update `FuneralSearchParams` type
|
|
||||||
|
|
||||||
## Known Limitations (deferred)
|
|
||||||
|
|
||||||
- **No progress indicator** — users can't see how many steps remain
|
|
||||||
- **No roving tabindex** — radiogroups use button elements with `role="radio"` but
|
|
||||||
arrow-key navigation between options is not implemented
|
|
||||||
- **No location autocomplete** — free text input only, validated on length
|
|
||||||
- **CSS vars used directly** — some styling uses `var(--fa-*)` tokens instead of
|
|
||||||
MUI theme paths; works but doesn't support dynamic theme switching
|
|
||||||
81
nginx/parsons-demos.conf
Normal file
81
nginx/parsons-demos.conf
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Parsons demo host — drop into swag's /config/nginx/site-confs/ directory.
|
||||||
|
#
|
||||||
|
# Serves static demo slices at parsons.tensordesign.com.au/<slice>/ behind
|
||||||
|
# basic auth. One server block, one cert (Let's Encrypt via swag), one
|
||||||
|
# htpasswd covering all slices.
|
||||||
|
#
|
||||||
|
# Document root layout (host filesystem):
|
||||||
|
# <host_path>/parsons-demos/
|
||||||
|
# index.html ← optional landing page listing slices
|
||||||
|
# arrangement/
|
||||||
|
# index.html
|
||||||
|
# assets/...
|
||||||
|
# <other-slices>/
|
||||||
|
#
|
||||||
|
# Bind-mount that directory into swag at /config/www/parsons-demos/ — the
|
||||||
|
# `root` directive below assumes that path. Adjust if you mount elsewhere.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name parsons.*;
|
||||||
|
|
||||||
|
# swag manages the cert chain via SUBDOMAINS — make sure `parsons` is in
|
||||||
|
# the SUBDOMAINS env var of the swag container so this resolves.
|
||||||
|
include /config/nginx/ssl.conf;
|
||||||
|
|
||||||
|
root /config/www/parsons-demos;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# One credential file covering every slice. Create with:
|
||||||
|
# docker exec -it swag htpasswd -c /config/nginx/.htpasswd-parsons client
|
||||||
|
auth_basic "Parsons demos";
|
||||||
|
auth_basic_user_file /config/nginx/.htpasswd-parsons;
|
||||||
|
|
||||||
|
# Optional: don't auth the root listing if you want it publicly visible.
|
||||||
|
# (Currently auth covers it too — change to `auth_basic off;` to expose.)
|
||||||
|
|
||||||
|
# Root path serves the optional landing index.html if present, else 404.
|
||||||
|
location = / {
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long cache for fingerprinted assets — Vite produces hashed filenames so
|
||||||
|
# this is safe. HTML is short-cache so updates land on next refresh.
|
||||||
|
# NOTE: asset + html regex locations must come BEFORE the slice fallback
|
||||||
|
# below, because nginx uses the first matching regex location.
|
||||||
|
location ~* \.(?:js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|webp|ico)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback per slice. /<slice>/<react-route> resolves to that
|
||||||
|
# slice's index.html so React Router handles the rest. Static assets
|
||||||
|
# (.js/.css/.png/etc.) are handled by the regex blocks above.
|
||||||
|
location ~ ^/(?<slice>[^/]+)/ {
|
||||||
|
try_files $uri $uri/ /$slice/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hide hidden files (e.g. .htpasswd if it ever ends up in webroot)
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect — swag's default server already covers this for
|
||||||
|
# wildcard subdomains, but include explicitly here in case the default is
|
||||||
|
# customised.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name parsons.*;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
173
package-lock.json
generated
173
package-lock.json
generated
@@ -10,11 +10,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@googlemaps/markerclusterer": "^2.6.2",
|
||||||
"@mui/icons-material": "^5.16.0",
|
"@mui/icons-material": "^5.16.0",
|
||||||
"@mui/material": "^5.16.0",
|
"@mui/material": "^5.16.0",
|
||||||
"@mui/system": "^5.16.0",
|
"@mui/system": "^5.16.0",
|
||||||
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0"
|
"react-dom": "^18.3.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -1458,6 +1462,26 @@
|
|||||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@googlemaps/js-api-loader": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/google.maps": "^3.53.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@googlemaps/markerclusterer": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"fast-equals": "^5.2.2",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -4106,6 +4130,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/google.maps": {
|
||||||
|
"version": "3.64.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
|
||||||
|
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -4169,6 +4205,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -4478,6 +4523,21 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vis.gl/react-google-maps": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@googlemaps/js-api-loader": "^2.0.2",
|
||||||
|
"@types/google.maps": "^3.54.10",
|
||||||
|
"fast-deep-equal": "^3.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -5387,6 +5447,19 @@
|
|||||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@@ -6458,9 +6531,17 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
@@ -7850,6 +7931,12 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9530,6 +9617,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||||
|
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||||
|
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.14.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
@@ -9901,6 +10026,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -10504,6 +10635,15 @@
|
|||||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -12099,6 +12239,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,23 @@
|
|||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
|
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
|
||||||
|
"demo:dev": "vite -c vite.demo.config.ts",
|
||||||
|
"demo:build": "vite build -c vite.demo.config.ts",
|
||||||
|
"demo:publish": "npm run demo:build -- --mode arrangement && ./scripts/deploy-demo.sh arrangement",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@googlemaps/markerclusterer": "^2.6.2",
|
||||||
"@mui/icons-material": "^5.16.0",
|
"@mui/icons-material": "^5.16.0",
|
||||||
"@mui/material": "^5.16.0",
|
"@mui/material": "^5.16.0",
|
||||||
"@mui/system": "^5.16.0",
|
"@mui/system": "^5.16.0",
|
||||||
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0"
|
"react-dom": "^18.3.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ClusterMarker } from './ClusterMarker';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ClusterMarker> = {
|
||||||
|
title: 'Atoms/ClusterMarker',
|
||||||
|
component: ClusterMarker,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'map',
|
||||||
|
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
onClick: { action: 'clicked' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ClusterMarker>;
|
||||||
|
|
||||||
|
/** Cluster containing at least one verified provider — promoted palette */
|
||||||
|
export const MixedOrVerified: Story = {
|
||||||
|
args: {
|
||||||
|
count: 5,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cluster of all-unverified providers — neutral palette */
|
||||||
|
export const AllUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
count: 3,
|
||||||
|
hasVerified: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Small cluster — pair of providers */
|
||||||
|
export const Pair: Story = {
|
||||||
|
args: {
|
||||||
|
count: 2,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Large cluster — double-digit count */
|
||||||
|
export const LargeCluster: Story = {
|
||||||
|
args: {
|
||||||
|
count: 27,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Side-by-side comparison — verified vs unverified at various counts */
|
||||||
|
export const PaletteGrid: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 6,
|
||||||
|
p: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClusterMarker count={2} hasVerified />
|
||||||
|
<ClusterMarker count={5} hasVerified />
|
||||||
|
<ClusterMarker count={12} hasVerified />
|
||||||
|
<ClusterMarker count={99} hasVerified />
|
||||||
|
<ClusterMarker count={2} />
|
||||||
|
<ClusterMarker count={5} />
|
||||||
|
<ClusterMarker count={12} />
|
||||||
|
<ClusterMarker count={99} />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA ClusterMarker atom */
|
||||||
|
export interface ClusterMarkerProps {
|
||||||
|
/** Number of providers in this cluster */
|
||||||
|
count: number;
|
||||||
|
/** True if any provider in the cluster is verified — drives the promoted palette */
|
||||||
|
hasVerified?: boolean;
|
||||||
|
/** Click handler — opens the cluster popup */
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||||
|
const BADGE_SIZE = 36;
|
||||||
|
|
||||||
|
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const colours = {
|
||||||
|
verified: {
|
||||||
|
bg: 'var(--fa-color-brand-700)',
|
||||||
|
text: 'var(--fa-color-white)',
|
||||||
|
border: 'var(--fa-color-brand-700)',
|
||||||
|
nub: 'var(--fa-color-brand-700)',
|
||||||
|
},
|
||||||
|
unverified: {
|
||||||
|
bg: 'var(--fa-color-neutral-100)',
|
||||||
|
text: 'var(--fa-color-neutral-800)',
|
||||||
|
border: 'var(--fa-color-neutral-300)',
|
||||||
|
nub: 'var(--fa-color-neutral-100)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cluster map marker for the FA design system.
|
||||||
|
*
|
||||||
|
* Circular pill with a count, representing N provider pins grouped at the
|
||||||
|
* same screen location. Sibling to `MapPin` — same palette language (verified
|
||||||
|
* promoted, unverified neutral), same nub treatment, same shadow.
|
||||||
|
*
|
||||||
|
* `hasVerified` drives the palette: if *any* provider in the cluster is
|
||||||
|
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
|
||||||
|
* clusters use the neutral palette.
|
||||||
|
*
|
||||||
|
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
|
||||||
|
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <ClusterMarker count={5} hasVerified onClick={...} />
|
||||||
|
* <ClusterMarker count={12} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
|
||||||
|
({ count, hasVerified = false, onClick, sx }, ref) => {
|
||||||
|
const palette = hasVerified ? colours.verified : colours.unverified;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(e as unknown as React.MouseEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = `${count} providers in this area`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 150ms ease-in-out',
|
||||||
|
// Fade in on mount — matches MapPin and popups for a consistent
|
||||||
|
// entry timing across the map.
|
||||||
|
'@keyframes clusterMarkerIn': {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 1 },
|
||||||
|
},
|
||||||
|
animation: 'clusterMarkerIn 180ms ease-out',
|
||||||
|
'&:hover': { transform: 'scale(1.08)' },
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: 'none',
|
||||||
|
'& > .ClusterMarker-badge': {
|
||||||
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Circular badge */}
|
||||||
|
<Box
|
||||||
|
className="ClusterMarker-badge"
|
||||||
|
sx={{
|
||||||
|
width: BADGE_SIZE,
|
||||||
|
height: BADGE_SIZE,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: palette.border,
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
color: palette.text,
|
||||||
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Nub — same SVG pattern as MapPin for visual continuity */}
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 16 8"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: `calc(2 * ${NUB_SIZE})`,
|
||||||
|
height: NUB_SIZE,
|
||||||
|
marginTop: '-1px',
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||||
|
<path
|
||||||
|
d="M 0 0 L 8 8 L 16 0"
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.border}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ClusterMarker.displayName = 'ClusterMarker';
|
||||||
|
export default ClusterMarker;
|
||||||
1
src/components/atoms/ClusterMarker/index.ts
Normal file
1
src/components/atoms/ClusterMarker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';
|
||||||
@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof MapPin>;
|
type Story = StoryObj<typeof MapPin>;
|
||||||
|
|
||||||
/** Verified provider with name and price — warm brand label */
|
/** Verified provider — promoted brand palette (dark copper bg, white text) */
|
||||||
export const VerifiedWithPrice: Story = {
|
export const Verified: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'H.Parsons Funeral Directors',
|
name: 'H.Parsons Funeral Directors',
|
||||||
price: 900,
|
price: 900,
|
||||||
@@ -31,7 +31,7 @@ export const VerifiedWithPrice: Story = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Unverified provider — neutral grey label */
|
/** Unverified provider — neutral grey label */
|
||||||
export const UnverifiedWithPrice: Story = {
|
export const Unverified: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Smith & Sons Funerals',
|
name: 'Smith & Sons Funerals',
|
||||||
price: 1200,
|
price: 1200,
|
||||||
@@ -39,66 +39,7 @@ export const UnverifiedWithPrice: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Active/selected state — inverted colours, slight scale-up */
|
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
|
||||||
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 = {
|
export const CustomPriceLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Premium Services',
|
name: 'Premium Services',
|
||||||
@@ -141,7 +82,7 @@ export const MapSimulation: Story = {
|
|||||||
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
||||||
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
|
<MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
||||||
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
||||||
@@ -152,12 +93,7 @@ export const MapSimulation: Story = {
|
|||||||
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
||||||
<MapPin name="Local Provider" onClick={() => {}} />
|
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Name only verified */}
|
|
||||||
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
|
|
||||||
<MapPin name="Kenneallys" verified onClick={() => {}} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
|||||||
|
|
||||||
/** Props for the FA MapPin atom */
|
/** Props for the FA MapPin atom */
|
||||||
export interface MapPinProps {
|
export interface MapPinProps {
|
||||||
/** Provider or venue name — omit for a price-only pill */
|
/** Provider or venue name (required — shown as line 1) */
|
||||||
name?: string;
|
name: string;
|
||||||
/** Starting package price in dollars — shown as "From $X" */
|
/** Starting package price in dollars — shown as "From $X" on line 2 */
|
||||||
price?: number;
|
price?: number;
|
||||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
/** Custom price label (e.g. "POA") — overrides formatted price */
|
||||||
priceLabel?: string;
|
priceLabel?: string;
|
||||||
/** Whether this provider/venue is verified (brand colour vs neutral) */
|
/** Whether this provider/venue is verified (brand palette vs neutral palette) */
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** Whether this pin is currently active/selected */
|
|
||||||
active?: boolean;
|
|
||||||
/** Click handler */
|
/** Click handler */
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
/** MUI sx prop for the root element */
|
/** MUI sx prop for the root element */
|
||||||
@@ -27,34 +25,24 @@ export interface MapPinProps {
|
|||||||
const PIN_PX = 'var(--fa-map-pin-padding-x)';
|
const PIN_PX = 'var(--fa-map-pin-padding-x)';
|
||||||
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
|
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
|
||||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||||
const MAX_WIDTH = 180;
|
const MAX_WIDTH = 210;
|
||||||
|
|
||||||
// ─── Colour sets ────────────────────────────────────────────────────────────
|
// ─── Colour sets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const colours = {
|
const colours = {
|
||||||
verified: {
|
verified: {
|
||||||
bg: 'var(--fa-color-brand-100)',
|
bg: 'var(--fa-color-brand-700)',
|
||||||
name: 'var(--fa-color-brand-900)',
|
name: 'var(--fa-color-white)',
|
||||||
price: 'var(--fa-color-brand-600)',
|
price: 'var(--fa-color-brand-200)',
|
||||||
activeBg: 'var(--fa-color-brand-700)',
|
nub: 'var(--fa-color-brand-700)',
|
||||||
activeName: 'var(--fa-color-white)',
|
border: 'var(--fa-color-brand-700)',
|
||||||
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: {
|
unverified: {
|
||||||
bg: 'var(--fa-color-neutral-100)',
|
bg: 'var(--fa-color-neutral-100)',
|
||||||
name: 'var(--fa-color-neutral-800)',
|
name: 'var(--fa-color-neutral-800)',
|
||||||
price: 'var(--fa-color-neutral-500)',
|
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)',
|
nub: 'var(--fa-color-neutral-100)',
|
||||||
activeNub: 'var(--fa-color-neutral-700)',
|
|
||||||
border: 'var(--fa-color-neutral-300)',
|
border: 'var(--fa-color-neutral-300)',
|
||||||
activeBorder: 'var(--fa-color-neutral-700)',
|
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -68,26 +56,25 @@ const colours = {
|
|||||||
* the exact map location.
|
* the exact map location.
|
||||||
*
|
*
|
||||||
* - **Line 1**: Provider name (bold, truncated)
|
* - **Line 1**: Provider name (bold, truncated)
|
||||||
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
|
* - **Line 2**: "From $X" (smaller, secondary colour)
|
||||||
*
|
*
|
||||||
* Visual distinction:
|
* Visual distinction:
|
||||||
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
* - **Verified** providers: warm brand palette (dark copper bg, white text)
|
||||||
* - **Unverified** providers: neutral grey palette
|
* - **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.
|
* Designed for use as custom HTML markers in Google Maps. Pure CSS — no
|
||||||
* Pure CSS — no canvas, no SVG dependency.
|
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
|
||||||
|
* organism level (ProviderMap swaps pin → popup on click).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
||||||
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
|
* <MapPin name="Smith & Sons" price={1200} />
|
||||||
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
|
* <MapPin name="Botanical" priceLabel="POA" verified />
|
||||||
* <MapPin name="H.Parsons" price={900} verified active />
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||||
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
({ name, price, priceLabel, verified = false, onClick, sx }, ref) => {
|
||||||
const palette = verified ? colours.verified : colours.unverified;
|
const palette = verified ? colours.verified : colours.unverified;
|
||||||
const hasPrice = price != null || priceLabel != null;
|
const hasPrice = price != null || priceLabel != null;
|
||||||
|
|
||||||
@@ -106,7 +93,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
|
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
sx={[
|
sx={[
|
||||||
@@ -116,7 +103,13 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'transform 150ms ease-in-out',
|
transition: 'transform 150ms ease-in-out',
|
||||||
transform: active ? 'scale(1.08)' : 'scale(1)',
|
// Fade in on mount — matches the popup's exit timing so the pin
|
||||||
|
// reappears smoothly when a popup closes.
|
||||||
|
'@keyframes mapPinIn': {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 1 },
|
||||||
|
},
|
||||||
|
animation: 'mapPinIn 180ms ease-out',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'scale(1.08)',
|
transform: 'scale(1.08)',
|
||||||
},
|
},
|
||||||
@@ -142,53 +135,65 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
py: 0.5,
|
py: 0.5,
|
||||||
px: PIN_PX,
|
px: PIN_PX,
|
||||||
borderRadius: PIN_RADIUS,
|
borderRadius: PIN_RADIUS,
|
||||||
backgroundColor: active ? palette.activeBg : palette.bg,
|
backgroundColor: palette.bg,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: active ? palette.activeBorder : palette.border,
|
borderColor: palette.border,
|
||||||
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
|
boxShadow: '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 row — verified icon (left) + name */}
|
||||||
{name && (
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{verified && (
|
||||||
|
// Inline SVG of Material's Verified (outlined) icon. Kept as
|
||||||
|
// inline SVG because MapPin is mounted via createRoot outside
|
||||||
|
// the MUI ThemeProvider, so @mui/icons-material wouldn't pick
|
||||||
|
// up theme defaults.
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ flexShrink: 0, fill: palette.name }}
|
||||||
|
>
|
||||||
|
<path d="M23 11.99l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.49 6.71 4.69 3.1 5.5l.34 3.7L1 11.99l2.44 2.79-.34 3.7 3.61.82 1.89 3.2L12 21.03l3.4 1.47 1.89-3.2 3.61-.82-.34-3.69L23 11.99zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
color: active ? palette.activeName : palette.name,
|
color: palette.name,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: '100%',
|
minWidth: 0,
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</Box>
|
||||||
|
|
||||||
{/* Price line */}
|
{/* Price line */}
|
||||||
{hasPrice && (
|
{hasPrice && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: !name ? 12 : 11,
|
fontSize: 11,
|
||||||
fontWeight: !name ? 700 : 600,
|
fontWeight: 600,
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
color: !name
|
color: palette.price,
|
||||||
? active
|
|
||||||
? palette.activeName
|
|
||||||
: palette.name
|
|
||||||
: active
|
|
||||||
? palette.activePrice
|
|
||||||
: palette.price,
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{priceText}
|
{priceText}
|
||||||
@@ -196,19 +201,33 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Nub — downward pointer */}
|
{/* Nub — downward pointer. Two SVG paths:
|
||||||
<Box
|
• fill is an extended pentagon that overhangs 3 units *into* the
|
||||||
|
pill's bg so sub-pixel scaling artifacts (hover transform) can't
|
||||||
|
expose the pill's bottom border through the seam;
|
||||||
|
• stroke is a separate open path on the two slanted sides only,
|
||||||
|
so the nub outline is continuous with the pill's border.
|
||||||
|
overflow: visible lets the fill render above the viewBox. */}
|
||||||
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
viewBox="0 0 16 8"
|
||||||
width: 0,
|
style={{
|
||||||
height: 0,
|
display: 'block',
|
||||||
borderLeft: `${NUB_SIZE} solid transparent`,
|
width: `calc(2 * ${NUB_SIZE})`,
|
||||||
borderRight: `${NUB_SIZE} solid transparent`,
|
height: NUB_SIZE,
|
||||||
borderTop: `${NUB_SIZE} solid`,
|
marginTop: '-1px',
|
||||||
borderTopColor: active ? palette.activeNub : palette.nub,
|
overflow: 'visible',
|
||||||
mt: '-1px',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||||
|
<path
|
||||||
|
d="M 0 0 L 8 8 L 16 0"
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.border}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal file
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ClusterPopup } from './ClusterPopup';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ClusterPopup> = {
|
||||||
|
title: 'Molecules/ClusterPopup',
|
||||||
|
component: ClusterPopup,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'map',
|
||||||
|
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ p: 4 }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ClusterPopup>;
|
||||||
|
|
||||||
|
// Fixture data — mirrors the shape used in the demo
|
||||||
|
const mixedCluster = [
|
||||||
|
{
|
||||||
|
id: 'parsons',
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.6,
|
||||||
|
startingPrice: 1800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funeral Services',
|
||||||
|
location: 'Warrawong, NSW',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.8,
|
||||||
|
startingPrice: 2450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wollongong-city',
|
||||||
|
name: 'Wollongong City Funerals',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.2,
|
||||||
|
startingPrice: 3400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'botanical',
|
||||||
|
name: 'Botanical Funerals',
|
||||||
|
location: 'Newtown, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.9,
|
||||||
|
startingPrice: 5200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Mixed-tier cluster — verified providers sorted to top */
|
||||||
|
export const Mixed: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster,
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Drill into ${id}`);
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
alert('Close cluster');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Small pair — two providers at the same location */
|
||||||
|
export const Pair: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.slice(0, 2),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All verified — every provider in the cluster is a partner */
|
||||||
|
export const AllVerified: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.filter((p) => p.verified),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All unverified — no partners in this cluster */
|
||||||
|
export const AllUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.filter((p) => !p.verified),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tall cluster — scrolls when providers exceed visible area */
|
||||||
|
export const TallCluster: Story = {
|
||||||
|
args: {
|
||||||
|
providers: [
|
||||||
|
...mixedCluster,
|
||||||
|
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
|
||||||
|
],
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal file
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||||
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A provider summary used in the cluster list */
|
||||||
|
export interface ClusterPopupProvider {
|
||||||
|
/** Unique provider ID */
|
||||||
|
id: string;
|
||||||
|
/** Provider display name */
|
||||||
|
name: string;
|
||||||
|
/** Location text (suburb, city) */
|
||||||
|
location: string;
|
||||||
|
/** Whether this is a verified/partner provider — drives sort order + colour accents */
|
||||||
|
verified?: boolean;
|
||||||
|
/** Average rating */
|
||||||
|
rating?: number;
|
||||||
|
/** Starting package price in dollars — shown as "From $X" on the right */
|
||||||
|
startingPrice?: number;
|
||||||
|
/** Custom price label (e.g. "POA") — overrides the formatted price */
|
||||||
|
priceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the FA ClusterPopup molecule */
|
||||||
|
export interface ClusterPopupProps {
|
||||||
|
/** Providers in this cluster */
|
||||||
|
providers: ClusterPopupProvider[];
|
||||||
|
/** Click handler — fires when a provider row is clicked */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Close handler — fires when the close button is clicked */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** When true, animates the popup out (opacity + scale) without unmounting.
|
||||||
|
* Callers should unmount after the transition completes (180ms). */
|
||||||
|
exiting?: boolean;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POPUP_WIDTH = 320;
|
||||||
|
const MAX_CONTENT_HEIGHT = 360;
|
||||||
|
const NUB_SIZE = 8;
|
||||||
|
/** Fixed width reserved for the verified-icon slot so all row titles share
|
||||||
|
* the same x-origin regardless of whether the row is verified. */
|
||||||
|
const VERIFIED_SLOT_WIDTH = 18;
|
||||||
|
|
||||||
|
// ─── Row sub-component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProviderRowProps {
|
||||||
|
provider: ClusterPopupProvider;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single provider row inside the cluster list. Image-free layout:
|
||||||
|
* verified-icon slot (fixed width so titles align across rows) + name +
|
||||||
|
* location/rating meta. Full-width clickable surface. Clicking triggers
|
||||||
|
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
|
||||||
|
* location and opens their single-provider popup.
|
||||||
|
*/
|
||||||
|
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
|
||||||
|
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
|
||||||
|
const priceText =
|
||||||
|
provider.priceLabel ??
|
||||||
|
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={(e) => {
|
||||||
|
// stopPropagation so the DOM click doesn't bubble to Map.onClick
|
||||||
|
// (which would clear state the same frame we're trying to drill in).
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
// flex-start so the verified-icon slot aligns with the name's top line,
|
||||||
|
// not the vertical centre of the row.
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1,
|
||||||
|
p: 1.25,
|
||||||
|
borderRadius: 1,
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 120ms ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: provider.verified
|
||||||
|
? 'var(--fa-color-brand-50)'
|
||||||
|
: 'var(--fa-color-surface-subtle)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Verified-icon slot — reserved width + fixed line-height so the icon
|
||||||
|
sits vertically on the name's line-box regardless of whether the
|
||||||
|
row has location/rating/price content below. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: VERIFIED_SLOT_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '1.25em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{provider.verified && (
|
||||||
|
<VerifiedOutlinedIcon
|
||||||
|
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
|
||||||
|
aria-label="Verified provider"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Text column — name + location/rating meta */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
|
||||||
|
minWidth: 0,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}
|
||||||
|
maxLines={1}
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
||||||
|
{provider.location}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{provider.rating != null && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
|
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
||||||
|
{provider.rating}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
|
||||||
|
Verified providers get the brand-600 copper price; unverified get
|
||||||
|
text.primary. "From" label uses caption/secondary for hierarchy. */}
|
||||||
|
{hasPrice && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
pt: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
|
||||||
|
From
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13,
|
||||||
|
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{priceText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cluster popup card for the FA design system.
|
||||||
|
*
|
||||||
|
* Appears when a cluster marker is clicked. Shows the providers grouped at
|
||||||
|
* that map location as a scrollable stack of image-free rows — each row: a
|
||||||
|
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
|
||||||
|
* provider name (copper for verified, neutral for unverified) + location and
|
||||||
|
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
|
||||||
|
* ProviderMap flow, that pans and zooms the map to the provider's location
|
||||||
|
* before opening their single-provider popup — restoring spatial context
|
||||||
|
* that a list-only popup otherwise loses.
|
||||||
|
*
|
||||||
|
* Verified providers are sorted to the top of the list (business outcome:
|
||||||
|
* promote partner providers in any crowded cluster).
|
||||||
|
*
|
||||||
|
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
|
||||||
|
* 320px width, same `surface-subtle` header bar convention. Designed to
|
||||||
|
* render inside a Google Maps `AdvancedMarker`.
|
||||||
|
*
|
||||||
|
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <ClusterPopup
|
||||||
|
* providers={[
|
||||||
|
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
|
||||||
|
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
|
||||||
|
* ]}
|
||||||
|
* onSelectProvider={(id) => drillIntoProvider(id)}
|
||||||
|
* onClose={() => closePopup()}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
|
||||||
|
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
|
||||||
|
// Verified-first sort (stable within each tier)
|
||||||
|
const sorted = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
|
||||||
|
[providers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
// Swallow clicks on any empty space inside the popup (header, scroll
|
||||||
|
// gutter, etc.) so they don't bubble to Map.onClick and close us.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
|
||||||
|
opacity: exiting ? 0 : 1,
|
||||||
|
transform: exiting ? 'scale(0.9)' : 'scale(1)',
|
||||||
|
'@keyframes clusterPopupIn': {
|
||||||
|
from: { opacity: 0, transform: 'scale(0.9)' },
|
||||||
|
to: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: POPUP_WIDTH,
|
||||||
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: MAX_CONTENT_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
|
||||||
|
{providers.length} providers in this area
|
||||||
|
</Typography>
|
||||||
|
{onClose && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
aria-label="Close cluster popup"
|
||||||
|
sx={{ mr: -0.5 }}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Provider list — scrollable */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
p: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
// Thin scrollbar styling
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
'&::-webkit-scrollbar': { width: 6 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sorted.map((p) => (
|
||||||
|
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
width={NUB_SIZE * 2}
|
||||||
|
height={NUB_SIZE}
|
||||||
|
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
|
||||||
|
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
|
||||||
|
fill="var(--fa-color-white)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ClusterPopup.displayName = 'ClusterPopup';
|
||||||
|
export default ClusterPopup;
|
||||||
1
src/components/molecules/ClusterPopup/index.ts
Normal file
1
src/components/molecules/ClusterPopup/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';
|
||||||
@@ -85,6 +85,36 @@ export const Empty: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Mobile ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mobile viewport — expanded by default, with a grey-filled right-chevron
|
||||||
|
* on the right of the pill. Tap the chevron to retract the pill to the
|
||||||
|
* right corner (the middle content animates to width:0, so the pill
|
||||||
|
* visually shrinks as one unit rather than swapping into a separate mini
|
||||||
|
* pill). Tap the left-chevron on the collapsed pill to expand. On add
|
||||||
|
* while collapsed, the full bar auto-peeks for 3s, then re-collapses. */
|
||||||
|
export const Mobile: Story = {
|
||||||
|
args: {
|
||||||
|
packages: samplePackages.slice(0, 2),
|
||||||
|
onCompare: () => alert('Compare clicked'),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
viewport: { defaultViewport: 'mobile1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mobile — single package state. Same behaviour as `Mobile`, Compare
|
||||||
|
* CTA disabled ("Add another to compare"). */
|
||||||
|
export const MobileSingle: Story = {
|
||||||
|
args: {
|
||||||
|
packages: samplePackages.slice(0, 1),
|
||||||
|
onCompare: () => alert('Compare clicked'),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
viewport: { defaultViewport: 'mobile1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// --- Interactive Demo --------------------------------------------------------
|
// --- Interactive Demo --------------------------------------------------------
|
||||||
|
|
||||||
/** Interactive demo — add packages and see the bar update */
|
/** Interactive demo — add packages and see the bar update */
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Slide from '@mui/material/Slide';
|
import Slide from '@mui/material/Slide';
|
||||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||||
|
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||||
|
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { Badge } from '../../atoms/Badge';
|
import { Badge } from '../../atoms/Badge';
|
||||||
@@ -31,6 +35,14 @@ export interface CompareBarProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** How long the bar stays expanded after a new package is added while
|
||||||
|
* collapsed. Long enough to read, short enough not to obstruct. */
|
||||||
|
const PEEK_DURATION_MS = 3000;
|
||||||
|
/** Middle-content expand/collapse duration (width + opacity). */
|
||||||
|
const COLLAPSE_MS = 300;
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,16 +51,54 @@ export interface CompareBarProps {
|
|||||||
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
|
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
|
||||||
* Present on both ProvidersStep and PackagesStep.
|
* Present on both ProvidersStep and PackagesStep.
|
||||||
*
|
*
|
||||||
* Composes Badge + Button + Typography.
|
* **Mobile collapse** (xs only): users can tap a right-chevron to retract
|
||||||
|
* the pill to the right edge — the middle content (status text + Compare
|
||||||
|
* button) animates to width:0 while the pill stays anchored at the same
|
||||||
|
* right offset, so the whole thing appears to shrink into the corner as
|
||||||
|
* one unit rather than two separate elements. Tap again to expand. When
|
||||||
|
* a new package is added while collapsed, the bar auto-peeks for
|
||||||
|
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
|
||||||
|
*
|
||||||
|
* Desktop (md+) stays expanded — there's plenty of space, and the
|
||||||
|
* collapse chevron is not rendered.
|
||||||
|
*
|
||||||
|
* Composes Badge + Button + Typography + IconButton.
|
||||||
*/
|
*/
|
||||||
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
||||||
({ packages, onCompare, error, sx }, ref) => {
|
({ packages, onCompare, error, sx }, ref) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const count = packages.length;
|
const count = packages.length;
|
||||||
const visible = count > 0;
|
const visible = count > 0;
|
||||||
const canCompare = count >= 2;
|
const canCompare = count >= 2;
|
||||||
|
|
||||||
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
|
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
|
||||||
|
|
||||||
|
// Collapse state — mobile only. Starts expanded; when the basket empties
|
||||||
|
// we reset so the next fresh fill starts visible.
|
||||||
|
const [collapsed, setCollapsed] = React.useState(false);
|
||||||
|
const [peeking, setPeeking] = React.useState(false);
|
||||||
|
const lastCountRef = React.useRef(count);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) setCollapsed(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Auto-peek when a package is added while collapsed.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const prev = lastCountRef.current;
|
||||||
|
lastCountRef.current = count;
|
||||||
|
if (collapsed && count > prev) {
|
||||||
|
setPeeking(true);
|
||||||
|
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [count, collapsed]);
|
||||||
|
|
||||||
|
/** Effective "is the middle content hidden?" — only on mobile, when the
|
||||||
|
* user has collapsed and we're not currently peeking. */
|
||||||
|
const mobileCollapsed = isMobile && collapsed && !peeking;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
|
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -58,52 +108,123 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={`${count} of 3 packages selected for comparison`}
|
aria-label={`${count} of 3 packages selected for comparison`}
|
||||||
sx={[
|
sx={[
|
||||||
(theme: Theme) => ({
|
(t: Theme) => ({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: theme.spacing(3),
|
// Clear the sticky HelpBar (~40px) + breathing room. FA theme
|
||||||
left: '50%',
|
// uses a 4px spacing base, so spacing(16) = 64px.
|
||||||
transform: 'translateX(-50%)',
|
bottom: t.spacing(16),
|
||||||
zIndex: theme.zIndex.snackbar,
|
// z-index sits below the mobile map-view drawer (modal: 1300)
|
||||||
|
// but above app chrome (appBar: 1100). snackbar (1400) was too
|
||||||
|
// aggressive — the drawer visually covers this bar on mobile.
|
||||||
|
zIndex: t.zIndex.drawer,
|
||||||
|
// Mobile: right-anchored so when the middle collapses the pill
|
||||||
|
// appears to retract to the right corner. Desktop: centered.
|
||||||
|
...(isMobile
|
||||||
|
? { right: t.spacing(4), left: 'auto' }
|
||||||
|
: { left: 0, right: 0, mx: 'auto' }),
|
||||||
|
width: 'fit-content',
|
||||||
borderRadius: '9999px',
|
borderRadius: '9999px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: { xs: 1.25, md: 2 },
|
||||||
px: 2.5,
|
px: { xs: 1.5, md: 3 },
|
||||||
py: 1.25,
|
py: { xs: 0.75, md: 1.5 },
|
||||||
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
|
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: `padding ${COLLAPSE_MS}ms ease-out`,
|
||||||
}),
|
}),
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Fraction badge — 1/3, 2/3, 3/3 */}
|
{/* Fraction badge — shows "N/3" when expanded, just "N" when
|
||||||
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
|
collapsed on mobile (reads as a circle at mini size). */}
|
||||||
{count}/3
|
<Badge
|
||||||
</Badge>
|
color="brand"
|
||||||
|
variant="soft"
|
||||||
{/* Status text */}
|
size={isMobile ? 'medium' : 'large'}
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
role={error ? 'alert' : undefined}
|
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
flexShrink: 0,
|
||||||
whiteSpace: 'nowrap',
|
// When collapsed, force the badge toward a circle by
|
||||||
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
|
// equalising min-width and min-height at the medium-badge
|
||||||
|
// height (26px).
|
||||||
|
...(mobileCollapsed && {
|
||||||
|
minWidth: 'var(--fa-badge-height-md)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 0,
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error || statusText}
|
{mobileCollapsed ? count : `${count}/3`}
|
||||||
</Typography>
|
</Badge>
|
||||||
|
|
||||||
{/* Compare CTA */}
|
{/* Middle content (status + Compare CTA) — animates to zero
|
||||||
<Button
|
max-width when collapsed, letting the pill shrink as one unit
|
||||||
variant="contained"
|
with the right edge staying fixed. */}
|
||||||
size="small"
|
<Box
|
||||||
startIcon={<CompareArrowsIcon />}
|
sx={{
|
||||||
onClick={onCompare}
|
display: 'flex',
|
||||||
disabled={!canCompare}
|
alignItems: 'center',
|
||||||
sx={{ flexShrink: 0, borderRadius: '9999px' }}
|
gap: { xs: 1.25, md: 2 },
|
||||||
|
maxWidth: mobileCollapsed ? 0 : 600,
|
||||||
|
opacity: mobileCollapsed ? 0 : 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
|
||||||
|
COLLAPSE_MS * 0.6,
|
||||||
|
)}ms ease-out`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Compare
|
<Typography
|
||||||
</Button>
|
variant={isMobile ? 'body2' : 'body1'}
|
||||||
|
role={error ? 'alert' : undefined}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error || statusText}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size={isMobile ? 'small' : 'medium'}
|
||||||
|
onClick={onCompare}
|
||||||
|
disabled={!canCompare}
|
||||||
|
tabIndex={mobileCollapsed ? -1 : 0}
|
||||||
|
sx={{ flexShrink: 0, borderRadius: '9999px' }}
|
||||||
|
>
|
||||||
|
Compare
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile-only collapse/expand chevron — grey-filled circle that
|
||||||
|
swaps icon direction based on state. Rendered at all times so
|
||||||
|
the IconButton container stays in the layout and the icon swap
|
||||||
|
happens in place without mount/unmount. */}
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
|
||||||
|
aria-expanded={!mobileCollapsed}
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'var(--fa-color-neutral-200)',
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mobileCollapsed ? (
|
||||||
|
<ChevronLeftRoundedIcon fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightRoundedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Slide>
|
</Slide>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ function formatPrice(amount: number): string {
|
|||||||
/**
|
/**
|
||||||
* Desktop column header card for the ComparisonTable.
|
* Desktop column header card for the ComparisonTable.
|
||||||
*
|
*
|
||||||
* Shows provider info (verified badge, name, location, rating), package name,
|
* Shows provider info (verified/recommended badge, name, location, rating),
|
||||||
* total price, CTA button, and optional Remove link. The verified badge floats
|
* package name, total price, CTA button, and optional Remove link. The badge
|
||||||
* above the card's top edge. Recommended packages get a copper banner and warm
|
* floats above the card's top edge — "Recommended" (primary fill) replaces
|
||||||
* selected card state.
|
* "Verified" (soft) when the package is recommended. Recommended packages
|
||||||
|
* also get a warm selected card state with a brand-600 border.
|
||||||
*
|
*
|
||||||
* Used as the sticky header for each column in the desktop comparison grid.
|
* Used as the sticky header for each column in the desktop comparison grid.
|
||||||
* Mobile comparison uses ComparisonPackageCard instead.
|
* Mobile comparison uses ComparisonPackageCard instead.
|
||||||
@@ -61,23 +62,29 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
|||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Floating verified badge — overlaps card top edge */}
|
{/* Floating badge — Recommended (primary fill) takes priority over Verified (soft) */}
|
||||||
{pkg.provider.verified && (
|
{(pkg.isRecommended || pkg.provider.verified) && (
|
||||||
<Badge
|
<Badge
|
||||||
color="brand"
|
color="brand"
|
||||||
variant="soft"
|
variant={pkg.isRecommended ? 'filled' : 'soft'}
|
||||||
size="small"
|
size="medium"
|
||||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
icon={
|
||||||
|
pkg.isRecommended ? (
|
||||||
|
<StarRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
) : (
|
||||||
|
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -12,
|
top: -13,
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verified
|
{pkg.isRecommended ? 'Recommended' : 'Verified'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -85,24 +92,16 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
selected={pkg.isRecommended}
|
selected={pkg.isRecommended}
|
||||||
padding="none"
|
padding="none"
|
||||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
...(pkg.isRecommended && {
|
||||||
|
borderColor: 'var(--fa-color-brand-600)',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{pkg.isRecommended && (
|
|
||||||
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
|
||||||
<Typography
|
|
||||||
variant="labelSm"
|
|
||||||
sx={{
|
|
||||||
color: 'var(--fa-color-white)',
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Recommended
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -110,40 +109,66 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
px: 2.5,
|
px: 2.5,
|
||||||
py: 2.5,
|
pt: 5,
|
||||||
pt: pkg.provider.verified ? 3 : 2.5,
|
pb: 3,
|
||||||
gap: 0.5,
|
gap: 1,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Provider name (truncated with tooltip) */}
|
{/* Provider name — always reserves space for 2 lines (via minHeight),
|
||||||
<Tooltip
|
content bottom-aligned so single-line names sit flush with the
|
||||||
title={pkg.provider.name}
|
next item below rather than floating high in the slot. */}
|
||||||
arrow
|
<Box
|
||||||
placement="top"
|
sx={{
|
||||||
disableHoverListener={pkg.provider.name.length < 24}
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
maxWidth: '100%',
|
||||||
|
minHeight: 36, // 2 × (14px label × 1.286 line-height)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
{pkg.isRecommended && (
|
||||||
variant="label"
|
<VerifiedOutlinedIcon
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontSize: 16,
|
||||||
overflow: 'hidden',
|
color: 'var(--fa-color-brand-600)',
|
||||||
textOverflow: 'ellipsis',
|
flexShrink: 0,
|
||||||
whiteSpace: 'nowrap',
|
mb: '2px',
|
||||||
maxWidth: '100%',
|
}}
|
||||||
}}
|
aria-label="Verified provider"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
title={pkg.provider.name}
|
||||||
|
arrow
|
||||||
|
placement="top"
|
||||||
|
disableHoverListener={pkg.provider.name.length < 50}
|
||||||
>
|
>
|
||||||
{pkg.provider.name}
|
<Typography
|
||||||
</Typography>
|
variant="label"
|
||||||
</Tooltip>
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pkg.provider.name}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{pkg.provider.location}
|
{pkg.provider.location}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Rating */}
|
{/* Rating (or dash placeholder to keep card heights consistent) */}
|
||||||
{pkg.provider.rating != null && (
|
{pkg.provider.rating != null ? (
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<StarRoundedIcon
|
<StarRoundedIcon
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||||
@@ -154,20 +179,35 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
|||||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider sx={{ width: '100%', my: 1 }} />
|
<Divider sx={{ width: '100%', my: 1.5 }} />
|
||||||
|
|
||||||
<Typography variant="h6" component="p">
|
<Typography variant="h6" component="p">
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
{/* Price subgroup — tighter internal spacing than the outer gap
|
||||||
Total package price
|
so the label sits close to the amount it describes. */}
|
||||||
</Typography>
|
<Box
|
||||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
sx={{
|
||||||
{formatPrice(pkg.price)}
|
display: 'flex',
|
||||||
</Typography>
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Total package price
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||||
|
{formatPrice(pkg.price)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Spacer pushes CTA to bottom across all cards */}
|
{/* Spacer pushes CTA to bottom across all cards */}
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
@@ -177,23 +217,33 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
|||||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||||
size="medium"
|
size="medium"
|
||||||
onClick={() => onArrange(pkg.id)}
|
onClick={() => onArrange(pkg.id)}
|
||||||
sx={{ mt: 1.5, px: 4 }}
|
sx={{ px: 4 }}
|
||||||
>
|
>
|
||||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!pkg.isRecommended && onRemove && (
|
{/* Always render the same Link element; hide when no Remove action
|
||||||
<Link
|
applies (recommended or no handler). Keeps the footer row
|
||||||
component="button"
|
identical across all cards so CTAs align. */}
|
||||||
variant="body2"
|
{(() => {
|
||||||
color="text.secondary"
|
const canRemove = !pkg.isRecommended && !!onRemove;
|
||||||
underline="hover"
|
return (
|
||||||
onClick={() => onRemove(pkg.id)}
|
<Link
|
||||||
sx={{ mt: 0.5 }}
|
component="button"
|
||||||
>
|
variant="caption"
|
||||||
Remove
|
color="text.secondary"
|
||||||
</Link>
|
underline="hover"
|
||||||
)}
|
onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
|
||||||
|
tabIndex={canRemove ? 0 : -1}
|
||||||
|
aria-hidden={!canRemove}
|
||||||
|
sx={{
|
||||||
|
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
import { Divider } from '../../atoms/Divider';
|
||||||
import { Card } from '../../atoms/Card';
|
import { Card } from '../../atoms/Card';
|
||||||
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
|
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
|
||||||
@@ -125,12 +124,21 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
<Card
|
<Card
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
selected={pkg.isRecommended}
|
|
||||||
padding="none"
|
padding="none"
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
// Body defaults to white; only the header carries the warm/subtle
|
||||||
|
// tint so the tint signals "provider" rather than washing the
|
||||||
|
// whole card.
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
// Match the desktop ComparisonColumnCard recommended treatment:
|
||||||
|
// explicit 2px brand-600 border (same as Card's selected state,
|
||||||
|
// but without the warm background wash that `selected` applies).
|
||||||
|
...(pkg.isRecommended && {
|
||||||
|
border: '2px solid var(--fa-color-brand-600)',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
@@ -158,31 +166,38 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
bgcolor: pkg.isRecommended
|
bgcolor: pkg.isRecommended
|
||||||
? 'var(--fa-color-surface-warm)'
|
? 'var(--fa-color-surface-warm)'
|
||||||
: 'var(--fa-color-surface-subtle)',
|
: 'var(--fa-color-surface-subtle)',
|
||||||
px: 2.5,
|
px: 3,
|
||||||
pt: 2.5,
|
pt: 3,
|
||||||
pb: 2,
|
pb: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Verified badge */}
|
{/* Provider name with optional inline verified icon (matches desktop
|
||||||
{pkg.provider.verified && (
|
ComparisonColumnCard treatment) */}
|
||||||
<Badge
|
<Box
|
||||||
color="brand"
|
sx={{
|
||||||
variant="soft"
|
display: 'flex',
|
||||||
size="small"
|
alignItems: 'center',
|
||||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
gap: 0.75,
|
||||||
sx={{ mb: 1 }}
|
mb: 1.25,
|
||||||
>
|
}}
|
||||||
Verified
|
>
|
||||||
</Badge>
|
{pkg.provider.verified && (
|
||||||
)}
|
<VerifiedOutlinedIcon
|
||||||
|
sx={{
|
||||||
{/* Provider name */}
|
fontSize: 16,
|
||||||
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
color: 'var(--fa-color-brand-600)',
|
||||||
{pkg.provider.name}
|
flexShrink: 0,
|
||||||
</Typography>
|
}}
|
||||||
|
aria-label="Verified provider"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="label" sx={{ fontWeight: 600 }}>
|
||||||
|
{pkg.provider.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Location + Rating */}
|
{/* Location + Rating */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
@@ -203,18 +218,22 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
{/* Package name + price */}
|
{/* Package info group — name, label, price stacked with small internal gap */}
|
||||||
<Typography variant="h5" component="p">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
{pkg.name}
|
<Typography variant="h5" component="p">
|
||||||
</Typography>
|
{pkg.name}
|
||||||
<Typography variant="caption" color="text.secondary">
|
</Typography>
|
||||||
Total package price
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
||||||
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
Total package price
|
||||||
{formatPrice(pkg.price)}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||||
|
{formatPrice(pkg.price)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||||
@@ -222,14 +241,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
size="medium"
|
size="medium"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => onArrange(pkg.id)}
|
onClick={() => onArrange(pkg.id)}
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 3 }}
|
||||||
>
|
>
|
||||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sections — with left accent borders on headings */}
|
{/* Sections — with left accent borders on headings */}
|
||||||
<Box sx={{ px: 2.5, py: 2.5 }}>
|
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}>
|
||||||
{pkg.itemizedAvailable === false ? (
|
{pkg.itemizedAvailable === false ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
@@ -238,15 +257,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
pkg.sections.map((section, sIdx) => (
|
pkg.sections.map((section, sIdx) => (
|
||||||
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
|
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 5 : 0 }}>
|
||||||
{/* Section heading with left accent */}
|
{/* Section heading with left accent */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
borderLeft: '3px solid',
|
borderLeft: '3px solid',
|
||||||
borderLeftColor: 'var(--fa-color-brand-500)',
|
borderLeftColor: 'var(--fa-color-brand-500)',
|
||||||
pl: 1.5,
|
pl: 1.5,
|
||||||
mb: 1.5,
|
mb: 2.5,
|
||||||
mt: sIdx > 0 ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" component="h3">
|
<Typography variant="h6" component="h3">
|
||||||
@@ -262,7 +280,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
py: 1.5,
|
py: 2,
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Badge } from '../../atoms/Badge';
|
import { Badge } from '../../atoms/Badge';
|
||||||
@@ -58,12 +59,15 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
|
|||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Recommended badge in normal flow — overlaps card via negative mb */}
|
{/* Recommended badge in normal flow — overlaps card via negative mb.
|
||||||
|
Matches the desktop ComparisonColumnCard styling (filled brand +
|
||||||
|
star icon) for consistency between surfaces. */}
|
||||||
{pkg.isRecommended ? (
|
{pkg.isRecommended ? (
|
||||||
<Badge
|
<Badge
|
||||||
color="brand"
|
color="brand"
|
||||||
variant="soft"
|
variant="filled"
|
||||||
size="small"
|
size="small"
|
||||||
|
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
|
||||||
sx={{
|
sx={{
|
||||||
mb: '-10px',
|
mb: '-10px',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
@@ -89,21 +93,18 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
interactive
|
interactive
|
||||||
sx={{
|
sx={{
|
||||||
width: 210,
|
width: 235,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
...(pkg.isRecommended && {
|
...(pkg.isRecommended && {
|
||||||
borderColor: 'var(--fa-color-brand-500)',
|
borderColor: 'var(--fa-color-brand-600)',
|
||||||
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
|
|
||||||
}),
|
}),
|
||||||
...(isActive && {
|
...(isActive && {
|
||||||
boxShadow: pkg.isRecommended
|
boxShadow: 'var(--fa-shadow-md)',
|
||||||
? '0 0 14px rgba(186, 131, 78, 0.4)'
|
|
||||||
: 'var(--fa-shadow-md)',
|
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
|
<Box sx={{ px: 2, pt: 3.5, pb: 2 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="labelSm"
|
variant="labelSm"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -77,8 +77,14 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
|||||||
title={label}
|
title={label}
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
{onClear && activeCount > 0 ? (
|
{onClear ? (
|
||||||
<Button variant="text" size="small" color="secondary" onClick={() => onClear()}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => onClear()}
|
||||||
|
disabled={activeCount === 0}
|
||||||
|
>
|
||||||
Reset filters
|
Reset filters
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
32
src/components/molecules/HelpBar/HelpBar.stories.tsx
Normal file
32
src/components/molecules/HelpBar/HelpBar.stories.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { HelpBar } from './HelpBar';
|
||||||
|
|
||||||
|
const meta: Meta<typeof HelpBar> = {
|
||||||
|
title: 'Molecules/HelpBar',
|
||||||
|
component: HelpBar,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'fullscreen' },
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
// Fake page content so the sticky footer has something to sit under.
|
||||||
|
<Box sx={{ minHeight: 400, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ flex: 1, p: 4, bgcolor: 'background.default' }}>
|
||||||
|
Page content scrolls above the help bar.
|
||||||
|
</Box>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof HelpBar>;
|
||||||
|
|
||||||
|
/** Default — uses FA's standard support number. */
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
/** Custom number — spaces preserved in the label, stripped in the tel link. */
|
||||||
|
export const CustomNumber: Story = {
|
||||||
|
args: { phone: '1300 000 000' },
|
||||||
|
};
|
||||||
64
src/components/molecules/HelpBar/HelpBar.tsx
Normal file
64
src/components/molecules/HelpBar/HelpBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import PhoneIcon from '@mui/icons-material/Phone';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA HelpBar molecule */
|
||||||
|
export interface HelpBarProps {
|
||||||
|
/** Phone number shown in the bar. Spaces preserved in the label,
|
||||||
|
* stripped in the `tel:` href. Defaults to FA's support number. */
|
||||||
|
phone?: string;
|
||||||
|
/** MUI sx prop — merged onto the default footer chrome. */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky help footer used at the bottom of every wizard page. Shows a
|
||||||
|
* phone-icon prefix + "Need help? Call us on" + the support number as a
|
||||||
|
* tel-link. White fill, top border, sticky to the viewport bottom.
|
||||||
|
*
|
||||||
|
* Used by `WizardLayout` (for all variants that don't set `hideHelpBar`)
|
||||||
|
* and by pages that bypass WizardLayout's chrome (e.g. the mobile-map-first
|
||||||
|
* layout on `ProvidersStep`). Promoted from a WizardLayout-internal
|
||||||
|
* component so both sources render an identical footer — preventing drift
|
||||||
|
* if the phone number or styling ever changes.
|
||||||
|
*/
|
||||||
|
export const HelpBar = React.forwardRef<HTMLDivElement, HelpBarProps>(
|
||||||
|
({ phone = '1800 987 888', sx }, ref) => (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
component="footer"
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
py: 1.5,
|
||||||
|
px: { xs: 2, md: 4 },
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" component="span">
|
||||||
|
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
|
||||||
|
Need help? Call us on{' '}
|
||||||
|
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
|
||||||
|
{phone}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
HelpBar.displayName = 'HelpBar';
|
||||||
|
export default HelpBar;
|
||||||
1
src/components/molecules/HelpBar/index.ts
Normal file
1
src/components/molecules/HelpBar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { HelpBar, type HelpBarProps } from './HelpBar';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { LocationSearchInput } from './LocationSearchInput';
|
||||||
|
|
||||||
|
const meta: Meta<typeof LocationSearchInput> = {
|
||||||
|
title: 'Molecules/LocationSearchInput',
|
||||||
|
component: LocationSearchInput,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'centered' },
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ width: 360, p: 2, bgcolor: 'background.default' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof LocationSearchInput>;
|
||||||
|
|
||||||
|
// Caller-provided chrome mirroring the ProvidersStep chip strip — useful
|
||||||
|
// for visualising the molecule in its real context. Users of the molecule
|
||||||
|
// on other surfaces would pass their own (or none).
|
||||||
|
const providerChromeSx = {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
borderRadius: 'var(--fa-button-border-radius-default)',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-root.Mui-focused': {
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Stories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Empty state — no committed value, no draft. The primary magnifying-glass
|
||||||
|
* stays anchored to the right edge. */
|
||||||
|
export const Empty: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Committed-chip state — the value renders as a chip with an X to clear. */
|
||||||
|
export const WithCommittedValue: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('Wollongong, 2500');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Unstyled — no caller chrome. Shows the raw molecule output (just the
|
||||||
|
* correctness CSS kicks in; the rest is MUI defaults). */
|
||||||
|
export const Unstyled: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** With onCommit side-effect — logs when the user explicitly commits
|
||||||
|
* (separate from the always-fired onChange). */
|
||||||
|
export const WithOnCommit: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return (
|
||||||
|
<LocationSearchInput
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onCommit={(v) => {
|
||||||
|
console.log('committed:', v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA LocationSearchInput molecule */
|
||||||
|
export interface LocationSearchInputProps {
|
||||||
|
/** Committed location value. When non-empty, rendered as a chip inside
|
||||||
|
* the input; when empty, placeholder shows and the input accepts typing. */
|
||||||
|
value: string;
|
||||||
|
/** Fires whenever the committed value changes — on explicit commit (Enter
|
||||||
|
* or search button) with the new value, or on chip delete with ''. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Optional extra callback fired *only* on explicit commit (not on chip
|
||||||
|
* delete). Useful for triggering search side-effects beyond the value
|
||||||
|
* update (analytics, external fetch, etc.). */
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
/** Placeholder text shown when no value is committed and no draft typed. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Accessible label for the input. */
|
||||||
|
'aria-label'?: string;
|
||||||
|
/** MUI sx prop — merged after the molecule's internal correctness CSS.
|
||||||
|
* Use this to style the outlined input's chrome (bgcolor, shadow, border,
|
||||||
|
* radius). Internal CSS targets `.MuiAutocomplete-inputRoot` whereas most
|
||||||
|
* chrome sx uses `.MuiOutlinedInput-root`, so collisions are avoided. */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal correctness CSS ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute-anchors the commit button (end adornment) to the right edge of
|
||||||
|
* the input — stock MUI Autocomplete does this on `.MuiAutocomplete-endAdornment`,
|
||||||
|
* but overriding `InputProps.endAdornment` puts our button inside a
|
||||||
|
* `.MuiInputAdornment-positionEnd` that defaults to `position: static` and
|
||||||
|
* would slide left as chips / draft text fill the input.
|
||||||
|
*
|
||||||
|
* `pr: 5` on the input root reserves the right-edge lane so input content
|
||||||
|
* can't run under the button. Selectors use `.MuiAutocomplete-inputRoot`
|
||||||
|
* (not `.MuiOutlinedInput-root`) so caller sx for chrome can sit alongside
|
||||||
|
* these rules without colliding on the same key.
|
||||||
|
*/
|
||||||
|
const INTERNAL_SX = {
|
||||||
|
'& .MuiAutocomplete-inputRoot': {
|
||||||
|
position: 'relative',
|
||||||
|
pr: 5,
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-inputRoot .MuiInputAdornment-positionEnd': {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: 'none',
|
||||||
|
m: 0,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location search input with committed-chip semantics.
|
||||||
|
*
|
||||||
|
* - **Typing produces a draft** (local state, not propagated).
|
||||||
|
* - **Pressing Enter or the primary-filled magnifying-glass button commits**
|
||||||
|
* the draft: fires `onChange(draft)` and `onCommit?.(draft)`, clears the
|
||||||
|
* draft, renders the committed value as a chip inside the input.
|
||||||
|
* - **Tapping the chip's X** clears the committed value (`onChange('')`).
|
||||||
|
*
|
||||||
|
* Capped to one chip at a time — if the user commits a new value while a
|
||||||
|
* chip exists, the new value replaces it. This matches the product intent
|
||||||
|
* (one active location per search) and keeps the UX obvious.
|
||||||
|
*
|
||||||
|
* The molecule owns the endAdornment absolute-anchoring + right-side
|
||||||
|
* padding so the commit button never drifts as chips / draft fill the input.
|
||||||
|
* Chrome (bgcolor, shadow, border, radius) is caller-controlled via `sx`.
|
||||||
|
*
|
||||||
|
* Originally extracted from ProvidersStep (D046) where the same pattern
|
||||||
|
* lived inline in both the mobile-map floating strip and the desktop/mobile
|
||||||
|
* sticky search bar.
|
||||||
|
*/
|
||||||
|
export const LocationSearchInput = React.forwardRef<HTMLDivElement, LocationSearchInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
placeholder = 'Search a town or suburb...',
|
||||||
|
'aria-label': ariaLabel = 'Search location',
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [draft, setDraft] = React.useState('');
|
||||||
|
|
||||||
|
const commit = (next: string) => {
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onChange(trimmed);
|
||||||
|
onCommit?.(trimmed);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
ref={ref}
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
forcePopupIcon={false}
|
||||||
|
clearIcon={null}
|
||||||
|
value={value.trim() ? [value.trim()] : []}
|
||||||
|
inputValue={draft}
|
||||||
|
onInputChange={(_, newDraft, reason) => {
|
||||||
|
// Autocomplete fires a 'reset' input-change after a commit that
|
||||||
|
// would echo the committed value back into our draft — ignore it.
|
||||||
|
if (reason === 'reset') return;
|
||||||
|
setDraft(newDraft);
|
||||||
|
}}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue.length === 0) {
|
||||||
|
// Chip deleted
|
||||||
|
onChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cap at 1: take the most-recent entry as the new committed value.
|
||||||
|
const last = newValue[newValue.length - 1];
|
||||||
|
if (typeof last === 'string') commit(last);
|
||||||
|
}}
|
||||||
|
renderTags={(val, getTagProps) =>
|
||||||
|
val.map((option, index) => {
|
||||||
|
const { key, ...chipProps } = getTagProps({ index });
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
label={option}
|
||||||
|
size="small"
|
||||||
|
aria-label={`Current location: ${option}. Press delete to clear.`}
|
||||||
|
{...chipProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={value.trim() ? '' : placeholder}
|
||||||
|
size="small"
|
||||||
|
inputProps={{
|
||||||
|
...params.inputProps,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<>
|
||||||
|
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||||||
|
</InputAdornment>
|
||||||
|
{params.InputProps.startAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={() => commit(draft)}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': { bgcolor: 'primary.dark' },
|
||||||
|
'&:focus-visible': { outline: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
LocationSearchInput.displayName = 'LocationSearchInput';
|
||||||
|
export default LocationSearchInput;
|
||||||
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';
|
||||||
@@ -132,7 +132,7 @@ export const WithPin: Story = {
|
|||||||
verified
|
verified
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
<MapPin name="H.Parsons" price={900} verified active />
|
<MapPin name="H.Parsons" price={900} verified />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface MapPopupProps {
|
|||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** Click handler — entire card is clickable */
|
/** Click handler — entire card is clickable */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/** When true, animates the popup out (opacity + scale) without unmounting.
|
||||||
|
* Callers should unmount after the transition completes (180ms). */
|
||||||
|
exiting?: boolean;
|
||||||
/** MUI sx prop for the root element */
|
/** MUI sx prop for the root element */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
capacity,
|
capacity,
|
||||||
verified = false,
|
verified = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
exiting = false,
|
||||||
sx,
|
sx,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -103,12 +107,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
}
|
}
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
|
// Swallow clicks on the popup so they don't bubble to an enclosing
|
||||||
|
// Map.onClick (which would close the popup mid-click). Always applied,
|
||||||
|
// even when onClick is unset, because callers consistently render this
|
||||||
|
// molecule inside a map context where ambient clicks should not escape.
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onClick ? 'button' : undefined}
|
role={onClick ? 'button' : undefined}
|
||||||
tabIndex={onClick ? 0 : undefined}
|
tabIndex={onClick ? 0 : undefined}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
onClick
|
onClick
|
||||||
? (e: React.KeyboardEvent) => {
|
? (e: React.KeyboardEvent) => {
|
||||||
@@ -127,12 +140,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
transition: 'transform 150ms ease-in-out',
|
transformOrigin: 'bottom center',
|
||||||
'&:hover': onClick
|
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
|
||||||
? {
|
opacity: exiting ? 0 : 1,
|
||||||
transform: 'scale(1.02)',
|
transform: exiting ? 'scale(0.9)' : 'scale(1)',
|
||||||
}
|
'@keyframes mapPopupIn': {
|
||||||
: undefined,
|
from: { opacity: 0, transform: 'scale(0.9)' },
|
||||||
|
to: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
|
||||||
|
'&:hover':
|
||||||
|
onClick && !exiting
|
||||||
|
? {
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
'&:focus-visible': {
|
'&:focus-visible': {
|
||||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
outlineOffset: '2px',
|
outlineOffset: '2px',
|
||||||
@@ -149,6 +171,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Image ── */}
|
{/* ── Image ── */}
|
||||||
@@ -279,19 +302,20 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Nub — downward pointer connecting to pin */}
|
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
|
||||||
<Box
|
for depth instead of a hard border, so no stroke needed) */}
|
||||||
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
width={NUB_SIZE * 2}
|
||||||
width: 0,
|
height={NUB_SIZE}
|
||||||
height: 0,
|
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
|
||||||
borderLeft: `${NUB_SIZE}px solid transparent`,
|
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
|
||||||
borderRight: `${NUB_SIZE}px solid transparent`,
|
>
|
||||||
borderTop: `${NUB_SIZE}px solid`,
|
<path
|
||||||
borderTopColor: 'background.paper',
|
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
|
||||||
mt: '-1px',
|
fill="var(--fa-color-white)"
|
||||||
}}
|
/>
|
||||||
/>
|
</svg>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { MapProviderDrawer } from './MapProviderDrawer';
|
||||||
|
|
||||||
|
const meta: Meta<typeof MapProviderDrawer> = {
|
||||||
|
title: 'Molecules/MapProviderDrawer',
|
||||||
|
component: MapProviderDrawer,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
viewport: { defaultViewport: 'mobile1' },
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
// Simulate the mobile map-view container: fixed-size, relatively-positioned,
|
||||||
|
// with a faux map background behind the drawer.
|
||||||
|
(Story) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 390,
|
||||||
|
height: 700,
|
||||||
|
mx: 'auto',
|
||||||
|
overflow: 'hidden',
|
||||||
|
// Very rough map-tile fill so the drawer has contrast behind it.
|
||||||
|
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof MapProviderDrawer>;
|
||||||
|
|
||||||
|
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const parsons = {
|
||||||
|
id: 'parsons',
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
verified: true,
|
||||||
|
imageUrl: '/images/funeral-homes/parsons-chapel.jpg',
|
||||||
|
logoUrl: '/images/providers/parsons-logo.png',
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 7,
|
||||||
|
startingPrice: 1800,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clusterProviders = [
|
||||||
|
parsons,
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funeral Services',
|
||||||
|
location: 'Warrawong, NSW',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.8,
|
||||||
|
startingPrice: 2450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'killick',
|
||||||
|
name: 'Killick Family Funerals',
|
||||||
|
location: 'Kingaroy, QLD',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.9,
|
||||||
|
startingPrice: 3100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wollongong-city',
|
||||||
|
name: 'Wollongong City Funerals',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.2,
|
||||||
|
startingPrice: 3400,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const log =
|
||||||
|
(label: string) =>
|
||||||
|
(arg?: string): void => {
|
||||||
|
console.log(label, arg ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Stories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Single-provider drawer — the whole ProviderCard is clickable and fires
|
||||||
|
* `onSelectProvider` (in production, this navigates to the packages page). */
|
||||||
|
export const SingleProvider: Story = {
|
||||||
|
args: {
|
||||||
|
active: {
|
||||||
|
provider: parsons,
|
||||||
|
cluster: null,
|
||||||
|
exiting: false,
|
||||||
|
},
|
||||||
|
onClose: log('close'),
|
||||||
|
onSelectProvider: log('select'),
|
||||||
|
onDrillIntoProvider: log('drillInto'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cluster drawer — verified-first list of rows. Tapping a row fires
|
||||||
|
* `onDrillIntoProvider`; in production this pans + zooms the map and
|
||||||
|
* swaps the drawer's `active` to a single-provider state. */
|
||||||
|
export const Cluster: Story = {
|
||||||
|
args: {
|
||||||
|
active: {
|
||||||
|
provider: null,
|
||||||
|
cluster: {
|
||||||
|
providers: clusterProviders,
|
||||||
|
position: { lat: -34.42, lng: 150.89 },
|
||||||
|
},
|
||||||
|
exiting: false,
|
||||||
|
},
|
||||||
|
onClose: log('close'),
|
||||||
|
onSelectProvider: log('select'),
|
||||||
|
onDrillIntoProvider: log('drillInto'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Closed state — the drawer is in the DOM but translated off-screen. */
|
||||||
|
export const Closed: Story = {
|
||||||
|
args: {
|
||||||
|
active: null,
|
||||||
|
onClose: log('close'),
|
||||||
|
onSelectProvider: log('select'),
|
||||||
|
onDrillIntoProvider: log('drillInto'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Small cluster of two — verified pair. */
|
||||||
|
export const ClusterPair: Story = {
|
||||||
|
args: {
|
||||||
|
active: {
|
||||||
|
provider: null,
|
||||||
|
cluster: {
|
||||||
|
providers: clusterProviders.slice(0, 2),
|
||||||
|
position: { lat: -34.42, lng: 150.89 },
|
||||||
|
},
|
||||||
|
exiting: false,
|
||||||
|
},
|
||||||
|
onClose: log('close'),
|
||||||
|
onSelectProvider: log('select'),
|
||||||
|
onDrillIntoProvider: log('drillInto'),
|
||||||
|
},
|
||||||
|
};
|
||||||
259
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
259
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||||
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { ProviderCard } from '../ProviderCard';
|
||||||
|
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||||
|
import type { ProviderMapActiveState } from '../../organisms/ProviderMap';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA MapProviderDrawer molecule */
|
||||||
|
export interface MapProviderDrawerProps {
|
||||||
|
/** Current active state from `ProviderMap` (wire via `onActiveChange`).
|
||||||
|
* `null` = no active pin/cluster; drawer is hidden. */
|
||||||
|
active: ProviderMapActiveState | null;
|
||||||
|
/** Fires when the close X is tapped. Typically wired to the map's
|
||||||
|
* imperative `clearActive()`. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fires when the single-provider card is tapped (entire card clickable).
|
||||||
|
* Typically navigates to that provider's packages. */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Fires when a cluster row is tapped. Typically wired to the map's
|
||||||
|
* imperative `drillIntoProvider()` which pans + zooms + swaps the
|
||||||
|
* drawer's content to a single-provider card. */
|
||||||
|
onDrillIntoProvider: (id: string) => void;
|
||||||
|
/** MUI sx prop for the root Paper — merged onto the default positioning. */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cluster row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ClusterRow: React.FC<{
|
||||||
|
provider: ProviderData;
|
||||||
|
onClick: () => void;
|
||||||
|
}> = ({ provider: p, onClick }) => (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
textAlign: 'left',
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
gap: 1,
|
||||||
|
// Start-align so the verified icon sits on the name's baseline —
|
||||||
|
// matches the desktop ClusterPopup row treatment.
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
'&:last-of-type': { borderBottom: 'none' },
|
||||||
|
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Verified-icon slot — reserved width + fixed line-height so the icon
|
||||||
|
sits on the name's line-box regardless of location/rating meta
|
||||||
|
below. Mirrors desktop ClusterPopup's treatment (D043 refinement). */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 18,
|
||||||
|
height: '1.25em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: p.verified ? 'primary.main' : 'text.primary',
|
||||||
|
lineHeight: 1.25,
|
||||||
|
mb: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
||||||
|
<Typography variant="caption">{p.location}</Typography>
|
||||||
|
{p.rating != null && (
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
|
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
|
||||||
|
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{p.startingPrice != null && (
|
||||||
|
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||||
|
From
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
|
||||||
|
>
|
||||||
|
${p.startingPrice.toLocaleString('en-AU')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom drawer that surfaces `ProviderMap`'s popup content outside the
|
||||||
|
* map itself. Used by the mobile map-first layout (see D045): the map
|
||||||
|
* runs full-bleed, and when a pin or cluster is tapped the drawer slides
|
||||||
|
* up from the bottom with the appropriate content.
|
||||||
|
*
|
||||||
|
* **Two content states, driven by `active`:**
|
||||||
|
* - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card
|
||||||
|
* clickable (fires `onSelectProvider`)
|
||||||
|
* - `active.cluster` → renders a verified-first list of rows (verified icon
|
||||||
|
* slot + name + location + rating + "From $X"); tapping a row fires
|
||||||
|
* `onDrillIntoProvider` which is wired to the map's imperative
|
||||||
|
* `drillIntoProvider()` (pans + zooms, then swaps `active` to that
|
||||||
|
* provider — the drawer content flips to the single-provider card).
|
||||||
|
*
|
||||||
|
* **Animation:** slides up via `transform: translateY()` + 220ms transition.
|
||||||
|
* When `active.exiting` is true, the drawer slides down immediately (the
|
||||||
|
* map organism is in the middle of its 180ms exit fade on the hidden pin
|
||||||
|
* beneath). `visibility: hidden` kicks in only after the slide completes,
|
||||||
|
* so the drawer stays in the DOM for the exit animation.
|
||||||
|
*
|
||||||
|
* **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0`
|
||||||
|
* by default — the consumer MUST render this inside a relatively-positioned
|
||||||
|
* container (typically the map-view `<main>`). Override via `sx` if needed.
|
||||||
|
*
|
||||||
|
* Related: row layout mirrors `ClusterPopup` (the anchored on-map variant);
|
||||||
|
* future consolidation possible if both container contracts converge.
|
||||||
|
*/
|
||||||
|
export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDrawerProps>(
|
||||||
|
({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => {
|
||||||
|
const provider = active?.provider ?? null;
|
||||||
|
const cluster = active?.cluster ?? null;
|
||||||
|
const isOpen = !!(active && !active.exiting && (provider || cluster));
|
||||||
|
const isExiting = !!active?.exiting;
|
||||||
|
|
||||||
|
const ariaLabel = provider
|
||||||
|
? `${provider.name} details`
|
||||||
|
: cluster
|
||||||
|
? `${cluster.providers.length} providers in this area`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
ref={ref}
|
||||||
|
elevation={0}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
sx={[
|
||||||
|
(t) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
// Sit above the floating CompareBar (which uses zIndex.drawer)
|
||||||
|
// so that when a pin or cluster is active the drawer visually
|
||||||
|
// covers the bar, not vice versa.
|
||||||
|
zIndex: t.zIndex.modal,
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
borderRadius: 0,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
boxShadow: 'var(--fa-shadow-lg)',
|
||||||
|
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||||
|
transition: 'transform 220ms ease-out',
|
||||||
|
pointerEvents: isOpen ? 'auto' : 'none',
|
||||||
|
visibility: isOpen || isExiting ? 'visible' : 'hidden',
|
||||||
|
}),
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Header strip — holds the close X (and the cluster count when
|
||||||
|
applicable) so neither sits over the card image below.
|
||||||
|
Horizontal padding matches the cluster rows (px: 2) so the
|
||||||
|
heading aligns with the row content beneath. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 40,
|
||||||
|
px: 2,
|
||||||
|
py: 0.5,
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cluster && !provider && (
|
||||||
|
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||||
|
{cluster.providers.length} providers in this area
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
ml: 'auto',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Single-provider content — entire card clickable. Card runs
|
||||||
|
edge-to-edge with all corners squared; the drawer Paper provides
|
||||||
|
the top radius. */}
|
||||||
|
{provider && (
|
||||||
|
<ProviderCard
|
||||||
|
name={provider.name}
|
||||||
|
location={provider.location}
|
||||||
|
verified={provider.verified}
|
||||||
|
imageUrl={provider.imageUrl}
|
||||||
|
logoUrl={provider.logoUrl}
|
||||||
|
rating={provider.rating}
|
||||||
|
reviewCount={provider.reviewCount}
|
||||||
|
startingPrice={provider.startingPrice}
|
||||||
|
onClick={() => onSelectProvider(provider.id)}
|
||||||
|
aria-label={`${provider.name}, ${provider.location}. Tap to view packages.`}
|
||||||
|
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster list content — tap a row to drill in */}
|
||||||
|
{cluster && !provider && (
|
||||||
|
<Box sx={{ pb: 1 }}>
|
||||||
|
{[...cluster.providers]
|
||||||
|
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
|
||||||
|
.map((p) => (
|
||||||
|
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MapProviderDrawer.displayName = 'MapProviderDrawer';
|
||||||
|
export default MapProviderDrawer;
|
||||||
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';
|
||||||
@@ -172,7 +172,10 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
|||||||
width: LOGO_SIZE,
|
width: LOGO_SIZE,
|
||||||
height: LOGO_SIZE,
|
height: LOGO_SIZE,
|
||||||
borderRadius: LOGO_BORDER_RADIUS,
|
borderRadius: LOGO_BORDER_RADIUS,
|
||||||
objectFit: 'cover',
|
// 'contain' so wide/tall logos scale proportionally inside
|
||||||
|
// the square slot rather than cropping. Background fills any
|
||||||
|
// letterboxed space so it still reads as a tile.
|
||||||
|
objectFit: 'contain',
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: 'background.paper',
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
border: '2px solid var(--fa-color-white)',
|
border: '2px solid var(--fa-color-white)',
|
||||||
|
|||||||
104
src/components/molecules/SortMenu/SortMenu.stories.tsx
Normal file
104
src/components/molecules/SortMenu/SortMenu.stories.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { SortMenu } from './SortMenu';
|
||||||
|
|
||||||
|
const meta: Meta<typeof SortMenu> = {
|
||||||
|
title: 'Molecules/SortMenu',
|
||||||
|
component: SortMenu,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'centered' },
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ p: 4 }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof SortMenu>;
|
||||||
|
|
||||||
|
const providerSortOptions = [
|
||||||
|
{ value: 'recommended', label: 'Recommended' },
|
||||||
|
{ value: 'nearest', label: 'Nearest' },
|
||||||
|
{ value: 'price_low', label: 'Price low to high' },
|
||||||
|
{ value: 'price_high', label: 'Price high to low' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Caller-provided chrome mirroring ProvidersStep's chip strip.
|
||||||
|
const controlChromeSx = {
|
||||||
|
height: 32,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderRadius: 'var(--fa-button-border-radius-default)',
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': { outline: 'none' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Stories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Compact variant — the trigger reads "Sort by" regardless of current
|
||||||
|
* value. Current value surfaces in the menu's selected state. Best for
|
||||||
|
* narrow layouts (mobile). */
|
||||||
|
export const Compact: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('recommended');
|
||||||
|
return <SortMenu {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
options: providerSortOptions,
|
||||||
|
variant: 'compact',
|
||||||
|
sx: controlChromeSx,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verbose variant — trigger reads "Sort: <current label>" with a
|
||||||
|
* swap-vertical icon. Best for desktop where horizontal space is cheap. */
|
||||||
|
export const Verbose: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('price_low');
|
||||||
|
return <SortMenu {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
options: providerSortOptions,
|
||||||
|
variant: 'verbose',
|
||||||
|
sx: controlChromeSx,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** No chrome — raw output. Useful for checking the molecule's default
|
||||||
|
* Button atom appearance before any caller sx. */
|
||||||
|
export const Bare: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('recommended');
|
||||||
|
return <SortMenu {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
options: providerSortOptions,
|
||||||
|
variant: 'compact',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Smaller option set — demonstrating that the component adapts to any
|
||||||
|
* options array, not just the provider-sort defaults. */
|
||||||
|
export const TwoOptions: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('newest');
|
||||||
|
return <SortMenu {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
options: [
|
||||||
|
{ value: 'newest', label: 'Newest first' },
|
||||||
|
{ value: 'oldest', label: 'Oldest first' },
|
||||||
|
],
|
||||||
|
variant: 'verbose',
|
||||||
|
sx: controlChromeSx,
|
||||||
|
},
|
||||||
|
};
|
||||||
118
src/components/molecules/SortMenu/SortMenu.tsx
Normal file
118
src/components/molecules/SortMenu/SortMenu.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A sort option shown in the menu */
|
||||||
|
export interface SortOption {
|
||||||
|
/** Machine-readable value (e.g. 'price_low'). Passed back via `onChange`. */
|
||||||
|
value: string;
|
||||||
|
/** Human-readable label (e.g. 'Price low to high'). Shown in the menu and,
|
||||||
|
* in the `verbose` variant, on the trigger button. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the FA SortMenu molecule */
|
||||||
|
export interface SortMenuProps {
|
||||||
|
/** Current sort value (controlled). Must match one of the options' values. */
|
||||||
|
value: string;
|
||||||
|
/** Fires when the user picks a different sort option. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Sort options to surface in the menu, in display order. */
|
||||||
|
options: SortOption[];
|
||||||
|
/** Trigger label variant:
|
||||||
|
* - `compact` (default): button reads just "Sort by"; current value
|
||||||
|
* surfaces only in the menu's selected item and in the aria-label.
|
||||||
|
* Best for narrow surfaces (mobile, chip-strip floating controls).
|
||||||
|
* - `verbose`: button reads "Sort: <current label>" with a leading
|
||||||
|
* swap-vertical icon. Best for desktop where horizontal space is
|
||||||
|
* cheap and the current value is worth surfacing inline. */
|
||||||
|
variant?: 'compact' | 'verbose';
|
||||||
|
/** MUI sx prop — applied to the trigger Button. Callers pass chrome
|
||||||
|
* (bgcolor, border, shadow, radius, height) here. */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown sort control — a trigger Button + anchored Menu.
|
||||||
|
*
|
||||||
|
* Tap the button → menu opens anchored to the button's bottom-right; pick
|
||||||
|
* an option → menu closes and `onChange` fires with the new value. The
|
||||||
|
* currently-selected option is visually marked in the menu (MUI's
|
||||||
|
* `selected` state on MenuItem).
|
||||||
|
*
|
||||||
|
* **Accessibility:** trigger button has `aria-haspopup="listbox"` and an
|
||||||
|
* `aria-label` that spells out the current sort ("Sort by Recommended"),
|
||||||
|
* so screen-reader users get the state regardless of which label variant
|
||||||
|
* is rendered. Selected MenuItem has `aria-selected="true"` via MUI.
|
||||||
|
*
|
||||||
|
* Originally extracted from ProvidersStep (which had the same Button +
|
||||||
|
* Menu pattern inline in two places with a minor "Sort by" vs
|
||||||
|
* "Sort: <label>" difference). Intended for reuse on VenueStep,
|
||||||
|
* CoffinsStep, or anywhere a sort menu is needed.
|
||||||
|
*/
|
||||||
|
export const SortMenu = React.forwardRef<HTMLButtonElement, SortMenuProps>(
|
||||||
|
({ value, onChange, options, variant = 'compact', sx }, ref) => {
|
||||||
|
const [anchor, setAnchor] = React.useState<null | HTMLElement>(null);
|
||||||
|
const current = options.find((o) => o.value === value);
|
||||||
|
const ariaLabel = `Sort by ${current?.label ?? 'default'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
startIcon={variant === 'verbose' ? <SwapVertIcon sx={{ fontSize: 16 }} /> : undefined}
|
||||||
|
onClick={(e) => setAnchor(e.currentTarget)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
sx={sx}
|
||||||
|
>
|
||||||
|
{variant === 'compact' ? (
|
||||||
|
'Sort by'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
|
||||||
|
Sort:
|
||||||
|
</Box>
|
||||||
|
{current?.label ?? ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchor}
|
||||||
|
open={Boolean(anchor)}
|
||||||
|
onClose={() => setAnchor(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
selected={opt.value === value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setAnchor(null);
|
||||||
|
}}
|
||||||
|
sx={{ fontSize: '0.813rem' }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SortMenu.displayName = 'SortMenu';
|
||||||
|
export default SortMenu;
|
||||||
1
src/components/molecules/SortMenu/index.ts
Normal file
1
src/components/molecules/SortMenu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SortMenu, type SortMenuProps, type SortOption } from './SortMenu';
|
||||||
@@ -346,7 +346,7 @@ export const MixedVerified: Story = {
|
|||||||
|
|
||||||
// --- Missing Itemised Data ---------------------------------------------------
|
// --- Missing Itemised Data ---------------------------------------------------
|
||||||
|
|
||||||
/** One provider has no itemised breakdown — cells show "—" */
|
/** One provider has no itemised breakdown — unverified cells show "Unknown" */
|
||||||
export const MissingData: Story = {
|
export const MissingData: Story = {
|
||||||
args: {
|
args: {
|
||||||
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
|
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
|
||||||
|
|||||||
@@ -63,7 +63,55 @@ function formatPrice(amount: number): string {
|
|||||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CellValue({ value }: { value: ComparisonCellValue }) {
|
/**
|
||||||
|
* Inline icon + label wrapper with optically aligned centres.
|
||||||
|
*
|
||||||
|
* body2's line-height adds vertical padding above/below the glyphs. Flex
|
||||||
|
* centring then aligns geometric centres, which puts the icon slightly
|
||||||
|
* above the text's visual centre. Setting `lineHeight: 1` on the row
|
||||||
|
* collapses the text line-box to the font size so geometric and visual
|
||||||
|
* centres match.
|
||||||
|
*/
|
||||||
|
function CellIconText({
|
||||||
|
icon,
|
||||||
|
iconPosition = 'leading',
|
||||||
|
color,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconPosition?: 'leading' | 'trailing';
|
||||||
|
color: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{iconPosition === 'leading' && icon}
|
||||||
|
<Typography variant="body2" sx={{ color, fontWeight: 500, lineHeight: 1 }} component="span">
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
{iconPosition === 'trailing' && icon}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sections where a missing item is better expressed as "Not Included"
|
||||||
|
* than a bare em-dash — these are opt-in items, so absence is meaningful. */
|
||||||
|
const OPTIONAL_SECTION_HEADINGS = new Set(['Optionals', 'Extras']);
|
||||||
|
|
||||||
|
function CellValue({
|
||||||
|
value,
|
||||||
|
sectionHeading,
|
||||||
|
}: {
|
||||||
|
value: ComparisonCellValue;
|
||||||
|
sectionHeading: string;
|
||||||
|
}) {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case 'price':
|
case 'price':
|
||||||
return (
|
return (
|
||||||
@@ -79,33 +127,31 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
|||||||
);
|
);
|
||||||
case 'complimentary':
|
case 'complimentary':
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
<CellIconText
|
||||||
<CheckCircleOutlineIcon
|
color="var(--fa-color-feedback-success)"
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
icon={
|
||||||
aria-hidden
|
<CheckCircleOutlineIcon
|
||||||
/>
|
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||||
<Typography
|
aria-hidden
|
||||||
variant="body2"
|
/>
|
||||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
}
|
||||||
>
|
>
|
||||||
Complimentary
|
Complimentary
|
||||||
</Typography>
|
</CellIconText>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
case 'included':
|
case 'included':
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
<CellIconText
|
||||||
<CheckCircleOutlineIcon
|
color="var(--fa-color-feedback-success)"
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
icon={
|
||||||
aria-hidden
|
<CheckCircleOutlineIcon
|
||||||
/>
|
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||||
<Typography
|
aria-hidden
|
||||||
variant="body2"
|
/>
|
||||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
}
|
||||||
>
|
>
|
||||||
Included
|
Included
|
||||||
</Typography>
|
</CellIconText>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
case 'poa':
|
case 'poa':
|
||||||
return (
|
return (
|
||||||
@@ -115,20 +161,30 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
|||||||
);
|
);
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
<CellIconText
|
||||||
|
color="var(--fa-color-neutral-500)"
|
||||||
|
iconPosition="trailing"
|
||||||
|
icon={
|
||||||
|
<InfoOutlinedIcon
|
||||||
|
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unknown
|
||||||
|
</CellIconText>
|
||||||
|
);
|
||||||
|
case 'unavailable':
|
||||||
|
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
|
||||||
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||||
>
|
>
|
||||||
Unknown
|
Not Included
|
||||||
</Typography>
|
</Typography>
|
||||||
<InfoOutlinedIcon
|
);
|
||||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
}
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'unavailable':
|
|
||||||
return (
|
return (
|
||||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
|
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
|
||||||
—
|
—
|
||||||
@@ -170,11 +226,20 @@ function lookupValue(
|
|||||||
sectionHeading: string,
|
sectionHeading: string,
|
||||||
itemName: string,
|
itemName: string,
|
||||||
): ComparisonCellValue {
|
): ComparisonCellValue {
|
||||||
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
|
// For unverified providers, absence means "we don't know" — data is
|
||||||
|
// scraped/estimated. For verified providers, absence means the package
|
||||||
|
// explicitly doesn't include this item (→ "Not Included" in Optionals/
|
||||||
|
// Extras; em-dash in Essentials as a safety net — canonical-essentials
|
||||||
|
// rule says every verified package has all 9, so this path shouldn't
|
||||||
|
// fire in practice).
|
||||||
|
const missing: ComparisonCellValue = pkg.provider.verified
|
||||||
|
? { type: 'unavailable' }
|
||||||
|
: { type: 'unknown' };
|
||||||
|
if (pkg.itemizedAvailable === false) return missing;
|
||||||
const section = pkg.sections.find((s) => s.heading === sectionHeading);
|
const section = pkg.sections.find((s) => s.heading === sectionHeading);
|
||||||
if (!section) return { type: 'unavailable' };
|
if (!section) return missing;
|
||||||
const item = section.items.find((i) => i.name === itemName);
|
const item = section.items.find((i) => i.name === itemName);
|
||||||
if (!item) return { type: 'unavailable' };
|
if (!item) return missing;
|
||||||
return item.value;
|
return item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +272,18 @@ const tableSx = {
|
|||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed column width for both the row-label column and each package column.
|
||||||
|
* Natural table width = COMPARISON_TABLE_COL_WIDTH × (packages.length + 1).
|
||||||
|
* Exposed so ComparisonPage can size its width-matching page header container
|
||||||
|
* to align left edges with the table on horizontal overflow.
|
||||||
|
*/
|
||||||
|
export const COMPARISON_TABLE_COL_WIDTH = 300;
|
||||||
|
|
||||||
|
/** z-index scale for sticky layers inside the table. */
|
||||||
|
const Z_STICKY_LEFT = 20;
|
||||||
|
const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,10 +296,10 @@ const tableSx = {
|
|||||||
*/
|
*/
|
||||||
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
||||||
({ packages, onArrange, onRemove, sx }, ref) => {
|
({ packages, onArrange, onRemove, sx }, ref) => {
|
||||||
const colCount = packages.length + 1;
|
|
||||||
const mergedSections = buildMergedSections(packages);
|
const mergedSections = buildMergedSections(packages);
|
||||||
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
|
const colCount = packages.length + 1;
|
||||||
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
|
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
|
||||||
|
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -232,32 +309,34 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
|||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
display: { xs: 'none', md: 'block' },
|
display: { xs: 'none', md: 'block' },
|
||||||
overflowX: 'auto',
|
width: COMPARISON_TABLE_COL_WIDTH * colCount,
|
||||||
},
|
},
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Box sx={{ minWidth: minW }}>
|
{/* ── Package header cards ── */}
|
||||||
{/* ── Package header cards ── */}
|
<Box
|
||||||
<Box
|
role="row"
|
||||||
role="row"
|
sx={{
|
||||||
sx={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: gridCols,
|
||||||
gridTemplateColumns: gridCols,
|
mb: 4,
|
||||||
gap: 2,
|
alignItems: 'stretch',
|
||||||
mb: 4,
|
pt: 3, // Room for floating verified badges
|
||||||
alignItems: 'stretch',
|
}}
|
||||||
pt: 3, // Room for floating verified badges
|
>
|
||||||
}}
|
{/* Info card — scrolls with the package columns. Previously
|
||||||
>
|
sticky-left to mirror the row-label column, but that pinned
|
||||||
{/* Info card — stretches to match package card height, text at top */}
|
it over the leftmost (recommended) package on horizontal
|
||||||
|
scroll. The row labels below stay sticky on their own. */}
|
||||||
|
<Box sx={{ px: 2 }}>
|
||||||
<Card
|
<Card
|
||||||
role="columnheader"
|
role="columnheader"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
padding="default"
|
padding="default"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
alignSelf: 'stretch',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
@@ -276,71 +355,117 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
|||||||
Review and compare features side-by-side to find the right fit.
|
Review and compare features side-by-side to find the right fit.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Package column header cards */}
|
{packages.map((pkg) => (
|
||||||
{packages.map((pkg) => (
|
<Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<ComparisonColumnCard
|
<ComparisonColumnCard
|
||||||
key={pkg.id}
|
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
onArrange={onArrange}
|
onArrange={onArrange}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||||
{mergedSections.map((section) => (
|
{mergedSections.map((section) => (
|
||||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||||
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
{/* Section heading row — left cell sticky so label stays visible on horizontal scroll */}
|
||||||
|
<Box
|
||||||
|
role="row"
|
||||||
|
sx={{
|
||||||
|
gridColumn: `1 / ${colCount + 1}`,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'subgrid',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'sticky',
|
||||||
|
left: 0,
|
||||||
|
zIndex: Z_STICKY_LEFT_SECTION,
|
||||||
|
gridColumn: '1 / 2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SectionHeading>{section.heading}</SectionHeading>
|
<SectionHeading>{section.heading}</SectionHeading>
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* Background continuation for the remaining columns so they
|
||||||
|
share the heading's surface-subtle wash. */}
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
gridColumn: `2 / ${colCount + 1}`,
|
||||||
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.name}
|
||||||
|
role="row"
|
||||||
|
sx={{
|
||||||
|
gridColumn: `1 / ${colCount + 1}`,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'subgrid',
|
||||||
|
// Tiered hover: base cells go to surface-subtle, recommended
|
||||||
|
// column cells inherit a warmer surface-warm tint on row hover.
|
||||||
|
'&:hover .comparison-cell': {
|
||||||
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
|
},
|
||||||
|
'&:hover .comparison-cell--recommended': {
|
||||||
|
bgcolor: 'var(--fa-color-surface-warm)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Row-label cell — sticky-left */}
|
||||||
<Box
|
<Box
|
||||||
key={item.name}
|
role="cell"
|
||||||
role="row"
|
className="comparison-cell comparison-cell--label"
|
||||||
sx={{
|
sx={{
|
||||||
gridColumn: `1 / ${colCount + 1}`,
|
position: 'sticky',
|
||||||
display: 'grid',
|
left: 0,
|
||||||
gridTemplateColumns: 'subgrid',
|
zIndex: Z_STICKY_LEFT,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
transition: 'background-color 0.15s ease',
|
transition: 'background-color 0.15s ease',
|
||||||
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Typography variant="body2" color="text.secondary" component="span">
|
||||||
role="cell"
|
{item.name}
|
||||||
sx={{
|
</Typography>
|
||||||
px: 3,
|
{item.info && (
|
||||||
py: 2,
|
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
borderTop: '1px solid',
|
{'\u00A0'}
|
||||||
borderColor: 'divider',
|
<Tooltip title={item.info} arrow placement="top">
|
||||||
}}
|
<InfoOutlinedIcon
|
||||||
>
|
aria-label={`More information about ${item.name}`}
|
||||||
<Typography variant="body2" color="text.secondary" component="span">
|
sx={{
|
||||||
{item.name}
|
fontSize: 14,
|
||||||
</Typography>
|
color: 'var(--fa-color-neutral-400)',
|
||||||
{item.info && (
|
cursor: 'help',
|
||||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
verticalAlign: 'middle',
|
||||||
{'\u00A0'}
|
}}
|
||||||
<Tooltip title={item.info} arrow placement="top">
|
/>
|
||||||
<InfoOutlinedIcon
|
</Tooltip>
|
||||||
aria-label={`More information about ${item.name}`}
|
</Box>
|
||||||
sx={{
|
)}
|
||||||
fontSize: 14,
|
</Box>
|
||||||
color: 'var(--fa-color-neutral-400)',
|
|
||||||
cursor: 'help',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg, idx) => {
|
||||||
|
const isRecommended = idx === recommendedColIdx;
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
role="cell"
|
role="cell"
|
||||||
|
className={
|
||||||
|
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -351,23 +476,26 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
|||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
borderLeft: '1px solid',
|
borderLeft: '1px solid',
|
||||||
borderLeftColor: 'divider',
|
borderLeftColor: 'divider',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
// Resting tint for the recommended column so it reads
|
||||||
|
// as the default column even without hover.
|
||||||
|
...(isRecommended && {
|
||||||
|
bgcolor:
|
||||||
|
'color-mix(in srgb, var(--fa-color-surface-warm) 50%, transparent)',
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
|
<CellValue
|
||||||
|
value={lookupValue(pkg, section.heading, item.name)}
|
||||||
|
sectionHeading={section.heading}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
</Box>
|
})}
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
))}
|
||||||
))}
|
</Box>
|
||||||
|
))}
|
||||||
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
* Some providers have not provided an itemised pricing breakdown. Their items are
|
|
||||||
shown as "—" above.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { ComparisonTable, default } from './ComparisonTable';
|
export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
|
||||||
export type {
|
export type {
|
||||||
ComparisonTableProps,
|
ComparisonTableProps,
|
||||||
ComparisonPackage,
|
ComparisonPackage,
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { PackageDetail } from './PackageDetail';
|
import { PackageDetail } from './PackageDetail';
|
||||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
|
||||||
import { Chip } from '../../atoms/Chip';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { Navigation } from '../Navigation';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
|
|
||||||
const DEMO_IMAGE =
|
|
||||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
|
||||||
|
|
||||||
const essentials = [
|
const essentials = [
|
||||||
{
|
{
|
||||||
@@ -117,41 +106,6 @@ const extras = {
|
|||||||
const termsText =
|
const termsText =
|
||||||
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
|
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
|
||||||
|
|
||||||
const packages = [
|
|
||||||
{
|
|
||||||
id: 'everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 900,
|
|
||||||
description:
|
|
||||||
'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'deluxe',
|
|
||||||
name: 'Deluxe Funeral Package',
|
|
||||||
price: 1200,
|
|
||||||
description: 'An enhanced package with premium coffin and additional floral arrangements.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'essential',
|
|
||||||
name: 'Essential Funeral Package',
|
|
||||||
price: 600,
|
|
||||||
description: 'A simple, dignified service covering all necessary arrangements.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'catholic',
|
|
||||||
name: 'Catholic Service',
|
|
||||||
price: 950,
|
|
||||||
description:
|
|
||||||
'A service tailored for Catholic traditions including prayers and church ceremony.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
|
|
||||||
|
|
||||||
const FALogoNav = () => (
|
|
||||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta: Meta<typeof PackageDetail> = {
|
const meta: Meta<typeof PackageDetail> = {
|
||||||
title: 'Organisms/PackageDetail',
|
title: 'Organisms/PackageDetail',
|
||||||
component: PackageDetail,
|
component: PackageDetail,
|
||||||
@@ -205,6 +159,24 @@ export const CompareLoading: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "Added to comparison" state — package is already in the basket.
|
||||||
|
* The Compare button keeps its default soft/secondary chrome + "Compare"
|
||||||
|
* label, and gains a trailing check icon. Click is a toggle — the
|
||||||
|
* caller wires `onCompare` to add-or-remove based on the `inCart` prop
|
||||||
|
* it's passing in (e.g. via `basket.toggle(key)`). aria-pressed and the
|
||||||
|
* aria-label spell out the state for SR users. */
|
||||||
|
export const InCart: Story = {
|
||||||
|
args: {
|
||||||
|
name: 'Traditional Family Cremation Service',
|
||||||
|
price: 6966,
|
||||||
|
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
||||||
|
total: 6966,
|
||||||
|
onArrange: () => alert('Make Arrangement'),
|
||||||
|
onCompare: () => {},
|
||||||
|
inCart: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// --- Without Extras ----------------------------------------------------------
|
// --- Without Extras ----------------------------------------------------------
|
||||||
|
|
||||||
/** Simpler package with essentials and optionals only — no extras */
|
/** Simpler package with essentials and optionals only — no extras */
|
||||||
@@ -222,132 +194,3 @@ export const WithoutExtras: Story = {
|
|||||||
onCompare: () => alert('Compare'),
|
onCompare: () => alert('Compare'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Package Select Page Layout ----------------------------------------------
|
|
||||||
|
|
||||||
/** Full page layout — left: package list, right: detail panel */
|
|
||||||
export const PackageSelectPage: Story = {
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ maxWidth: 'none', width: '100%' }}>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
render: () => {
|
|
||||||
const [selectedPkg, setSelectedPkg] = useState('everyday');
|
|
||||||
const [activeFilter, setActiveFilter] = useState('Cremation');
|
|
||||||
const [comparing, setComparing] = useState(false);
|
|
||||||
|
|
||||||
const handleCompare = () => {
|
|
||||||
setComparing(true);
|
|
||||||
setTimeout(() => setComparing(false), 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Navigation
|
|
||||||
logo={<FALogoNav />}
|
|
||||||
items={[
|
|
||||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
|
||||||
gap: { xs: 3, md: 4 },
|
|
||||||
maxWidth: 'lg',
|
|
||||||
mx: 'auto',
|
|
||||||
px: { xs: 2, md: 4 },
|
|
||||||
py: { xs: 2, md: 4 },
|
|
||||||
alignItems: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left column */}
|
|
||||||
<Box>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
color="secondary"
|
|
||||||
startIcon={<ArrowBackIcon />}
|
|
||||||
sx={{ mb: 2, ml: -1 }}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
|
||||||
Select a package
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<ProviderCardCompact
|
|
||||||
name="H.Parsons"
|
|
||||||
location="Wentworth"
|
|
||||||
imageUrl={DEMO_IMAGE}
|
|
||||||
rating={4.5}
|
|
||||||
reviewCount={11}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Funeral type filter */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
|
||||||
{funeralTypes.map((type) => (
|
|
||||||
<Chip
|
|
||||||
key={type}
|
|
||||||
label={type}
|
|
||||||
variant={activeFilter === type ? 'filled' : 'outlined'}
|
|
||||||
selected={activeFilter === type}
|
|
||||||
onClick={() => setActiveFilter(type)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
|
||||||
Packages
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Available packages"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
|
||||||
>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<ServiceOption
|
|
||||||
key={pkg.id}
|
|
||||||
name={pkg.name}
|
|
||||||
price={pkg.price}
|
|
||||||
description={pkg.description}
|
|
||||||
selected={selectedPkg === pkg.id}
|
|
||||||
onClick={() => setSelectedPkg(pkg.id)}
|
|
||||||
maxDescriptionLines={2}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right column: package detail */}
|
|
||||||
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
|
|
||||||
<PackageDetail
|
|
||||||
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
|
|
||||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
|
||||||
sections={[
|
|
||||||
{ heading: 'Essentials', items: essentials },
|
|
||||||
{ heading: 'Optionals', items: optionals },
|
|
||||||
]}
|
|
||||||
total={6966}
|
|
||||||
extras={extras}
|
|
||||||
terms={termsText}
|
|
||||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
|
||||||
onCompare={handleCompare}
|
|
||||||
compareLoading={comparing}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
@@ -53,6 +56,11 @@ export interface PackageDetailProps {
|
|||||||
arrangeDisabled?: boolean;
|
arrangeDisabled?: boolean;
|
||||||
/** Whether the compare button is in loading state */
|
/** Whether the compare button is in loading state */
|
||||||
compareLoading?: boolean;
|
compareLoading?: boolean;
|
||||||
|
/** Whether this package is already in the comparison basket. When true,
|
||||||
|
* the Compare button swaps its label to "Added" and adds a leading check
|
||||||
|
* icon. The button remains clickable — the caller is expected to treat
|
||||||
|
* `onCompare` as a toggle (add when not in cart, remove when in cart). */
|
||||||
|
inCart?: boolean;
|
||||||
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
|
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
|
||||||
arrangeLabel?: string;
|
arrangeLabel?: string;
|
||||||
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
|
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
|
||||||
@@ -124,6 +132,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
terms,
|
terms,
|
||||||
onArrange,
|
onArrange,
|
||||||
onCompare,
|
onCompare,
|
||||||
|
inCart = false,
|
||||||
arrangeDisabled = false,
|
arrangeDisabled = false,
|
||||||
compareLoading = false,
|
compareLoading = false,
|
||||||
arrangeLabel = 'Make Arrangement',
|
arrangeLabel = 'Make Arrangement',
|
||||||
@@ -133,6 +142,11 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
// CTA buttons stay side-by-side on all viewports; size down on xs so
|
||||||
|
// "Make Arrangement" + "Compare" fit a ~360px mobile column without wrap.
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const ctaSize = isMobile ? 'medium' : 'large';
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -141,6 +155,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
|
boxShadow: 'var(--fa-card-shadow-default)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
@@ -149,7 +164,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
{/* Header band — warm bg to separate from content */}
|
{/* Header band — warm bg to separate from content */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-warm)',
|
bgcolor: 'background.paper',
|
||||||
px: { xs: 2, sm: 3 },
|
px: { xs: 2, sm: 3 },
|
||||||
pt: 3,
|
pt: 3,
|
||||||
pb: 2.5,
|
pb: 2.5,
|
||||||
@@ -178,10 +193,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 1,
|
gap: 1.25,
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
px: 1.5,
|
px: 2,
|
||||||
py: 1,
|
py: 1.5,
|
||||||
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
||||||
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
@@ -189,22 +204,20 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InfoOutlinedIcon
|
<InfoOutlinedIcon
|
||||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
|
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
|
||||||
{priceDisclaimer}
|
{priceDisclaimer}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA buttons */}
|
{/* CTA buttons — always side-by-side */}
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1.5, mt: 2.5 }}>
|
||||||
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size={ctaSize}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={arrangeDisabled}
|
disabled={arrangeDisabled}
|
||||||
onClick={onArrange}
|
onClick={onArrange}
|
||||||
@@ -212,12 +225,19 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
{arrangeLabel}
|
{arrangeLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
|
// Same soft/secondary chrome + "Compare" label in both states;
|
||||||
|
// when the package is in the basket a trailing check icon
|
||||||
|
// appears. Click is a toggle — caller decides to add or remove
|
||||||
|
// based on the `inCart` it's passing in.
|
||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="large"
|
size={ctaSize}
|
||||||
loading={compareLoading}
|
loading={compareLoading}
|
||||||
|
endIcon={inCart ? <CheckRoundedIcon /> : undefined}
|
||||||
onClick={onCompare}
|
onClick={onCompare}
|
||||||
|
aria-pressed={inCart}
|
||||||
|
aria-label={inCart ? 'Remove from comparison' : 'Add to comparison'}
|
||||||
sx={{ flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Compare
|
Compare
|
||||||
|
|||||||
110
src/components/organisms/ProviderMap/ProviderMap.stories.tsx
Normal file
110
src/components/organisms/ProviderMap/ProviderMap.stories.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ProviderMap } from './ProviderMap';
|
||||||
|
import { providers as demoProviders } from '../../../demo/shared/fixtures/providers';
|
||||||
|
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ProviderMap> = {
|
||||||
|
title: 'Organisms/ProviderMap',
|
||||||
|
component: ProviderMap,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Google Map showing provider pins with click-to-open popup. Uses the MapPin atom for markers and the MapPopup molecule for the popup card. Auto-fits the viewport to all providers with coords. Clicking a popup triggers `onSelectProvider`.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ width: '100vw', height: '100vh', display: 'flex' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ProviderMap>;
|
||||||
|
|
||||||
|
// Cast: DemoProvider adds `tier` over ProviderData, structural subset for the map
|
||||||
|
const providers = demoProviders as ProviderData[];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** All 7 demo providers with real NSW/QLD coordinates. Map fits bounds across them. */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Navigate to provider ${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One provider pre-selected — its pin renders in the active (inverted) state. */
|
||||||
|
export const WithSelectedProvider: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
selectedProviderId: 'parsons',
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Navigate to provider ${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Interactive demo — clicking a popup clears/re-selects as if navigating. */
|
||||||
|
export const InteractiveSelection: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const StoryWrapper = () => {
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<ProviderMap
|
||||||
|
{...args}
|
||||||
|
selectedProviderId={selected}
|
||||||
|
onSelectProvider={(id) => setSelected((prev) => (prev === id ? null : id))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return <StoryWrapper />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Providers without coords — falls back to the "Map unavailable" empty state. */
|
||||||
|
export const NoCoords: Story = {
|
||||||
|
args: {
|
||||||
|
providers: providers.map(({ coords: _omit, ...p }) => p),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** No API key supplied — renders the empty state without attempting to load Google Maps. */
|
||||||
|
export const NoApiKey: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
apiKey: '',
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Single provider — map centres on that coord with zoom 13. */
|
||||||
|
export const SingleProvider: Story = {
|
||||||
|
args: {
|
||||||
|
providers: [providers[0]],
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mixed — some providers with coords, some without. Only those with coords render. */
|
||||||
|
export const PartialCoords: Story = {
|
||||||
|
args: {
|
||||||
|
providers: providers.map((p, i) => (i % 2 === 0 ? p : { ...p, coords: undefined })),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
549
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
549
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
APIProvider,
|
||||||
|
Map as GoogleMap,
|
||||||
|
AdvancedMarker,
|
||||||
|
useMap,
|
||||||
|
useMapsLibrary,
|
||||||
|
} from '@vis.gl/react-google-maps';
|
||||||
|
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
|
||||||
|
import { MapPin } from '../../atoms/MapPin';
|
||||||
|
import { ClusterMarker } from '../../atoms/ClusterMarker';
|
||||||
|
import { MapPopup } from '../../molecules/MapPopup';
|
||||||
|
import { ClusterPopup } from '../../molecules/ClusterPopup';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Sydney — fallback centre when no providers have coords and no default supplied */
|
||||||
|
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
|
||||||
|
const FALLBACK_ZOOM = 5;
|
||||||
|
/** Google Maps requires a mapId for AdvancedMarker support */
|
||||||
|
const MAP_ID = 'fa-provider-map';
|
||||||
|
/** fitBounds padding (applied as google.maps.Padding) */
|
||||||
|
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
|
||||||
|
/** Screen-pixel radius at which nearby pins collapse into a cluster */
|
||||||
|
const CLUSTER_GRID_SIZE = 70;
|
||||||
|
/** Zoom level above which clustering is disabled (pins show individually) */
|
||||||
|
const CLUSTER_MAX_ZOOM = 13;
|
||||||
|
/** Zoom level the map animates to on cluster drill-in (street-level, past
|
||||||
|
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
|
||||||
|
const DRILL_IN_ZOOM = 15;
|
||||||
|
/** Exit-animation duration for popups on close — keep in sync with the
|
||||||
|
* transition values set on MapPopup/ClusterPopup. */
|
||||||
|
const POPUP_EXIT_MS = 180;
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Shape of the currently-active provider or cluster selection, emitted to
|
||||||
|
* callers that opt into external popup rendering (see `externalisePopups`). */
|
||||||
|
export interface ProviderMapActiveState {
|
||||||
|
/** Active single provider, if a pin was tapped (or a cluster row drilled into) */
|
||||||
|
provider: ProviderData | null;
|
||||||
|
/** Active cluster, if a cluster marker was tapped and no row has been drilled into */
|
||||||
|
cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null;
|
||||||
|
/** True while the exit animation is running — callers may want to mirror it */
|
||||||
|
exiting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imperative handle exposed via ref. Used when rendering popups externally. */
|
||||||
|
export interface ProviderMapHandle {
|
||||||
|
/** Close the currently-active popup (animated). No-op if nothing is open. */
|
||||||
|
clearActive: () => void;
|
||||||
|
/** Pan + zoom the map to a provider's coords and set them as the active
|
||||||
|
* single-provider selection. Equivalent to a cluster-row tap. */
|
||||||
|
drillIntoProvider: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the FA ProviderMap organism */
|
||||||
|
export interface ProviderMapProps {
|
||||||
|
/** Providers to render as pins. Providers without coords are filtered out silently. */
|
||||||
|
providers: ProviderData[];
|
||||||
|
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
|
||||||
|
selectedProviderId?: string | null;
|
||||||
|
/** Called when the user clicks through a popup — usually triggers navigation */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Initial map centre — used only when no providers have coords */
|
||||||
|
defaultCenter?: { lat: number; lng: number };
|
||||||
|
/** Initial zoom — used only when no providers have coords */
|
||||||
|
defaultZoom?: number;
|
||||||
|
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
|
||||||
|
apiKey?: string;
|
||||||
|
/** When true, suppress the organism's own MapPopup + ClusterPopup rendering.
|
||||||
|
* The active state is still tracked internally (pins still hide when active)
|
||||||
|
* and emitted via `onActiveChange` so callers can render a drawer, sheet,
|
||||||
|
* or other external container. Used by the mobile map-first layout. */
|
||||||
|
externalisePopups?: boolean;
|
||||||
|
/** Fires whenever the active provider/cluster state changes. Paired with
|
||||||
|
* `externalisePopups` — the caller uses this to drive external UI. */
|
||||||
|
onActiveChange?: (state: ProviderMapActiveState) => void;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveCluster {
|
||||||
|
providers: ProviderData[];
|
||||||
|
position: google.maps.LatLngLiteral;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal components ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fits the map to the bounds of all providers with coords. Runs whenever the
|
||||||
|
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
|
||||||
|
*/
|
||||||
|
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
|
||||||
|
const map = useMap();
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const withCoords = providers.filter((p) => p.coords);
|
||||||
|
if (withCoords.length === 0) return;
|
||||||
|
if (withCoords.length === 1) {
|
||||||
|
map.setCenter(withCoords[0].coords!);
|
||||||
|
map.setZoom(13);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = new window.google.maps.LatLngBounds();
|
||||||
|
withCoords.forEach((p) => bounds.extend(p.coords!));
|
||||||
|
map.fitBounds(bounds, BOUNDS_PADDING);
|
||||||
|
}, [map, providers]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the Google Map instance into a parent ref so imperative
|
||||||
|
* actions (panTo, setZoom) can be triggered from outside the Map context.
|
||||||
|
*/
|
||||||
|
const MapRefCapture: React.FC<{
|
||||||
|
mapRef: React.MutableRefObject<google.maps.Map | null>;
|
||||||
|
}> = ({ mapRef }) => {
|
||||||
|
const map = useMap();
|
||||||
|
React.useEffect(() => {
|
||||||
|
mapRef.current = map;
|
||||||
|
}, [map, mapRef]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative marker layer — builds AdvancedMarker instances with React
|
||||||
|
* content, groups them via MarkerClusterer, and rebuilds whenever the
|
||||||
|
* visible provider set changes.
|
||||||
|
*
|
||||||
|
* Providers listed in `hiddenIds` are excluded from the map (their popup is
|
||||||
|
* currently showing instead).
|
||||||
|
*/
|
||||||
|
const MarkerLayer: React.FC<{
|
||||||
|
providers: ProviderData[];
|
||||||
|
hiddenIds: Set<string>;
|
||||||
|
onPinClick: (id: string) => void;
|
||||||
|
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
|
||||||
|
}> = ({ providers, hiddenIds, onPinClick, onClusterClick }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const markerLibrary = useMapsLibrary('marker');
|
||||||
|
|
||||||
|
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
|
||||||
|
// every marker) when the parent passes fresh arrow-function references.
|
||||||
|
const onPinClickRef = React.useRef(onPinClick);
|
||||||
|
const onClusterClickRef = React.useRef(onClusterClick);
|
||||||
|
React.useEffect(() => {
|
||||||
|
onPinClickRef.current = onPinClick;
|
||||||
|
onClusterClickRef.current = onClusterClick;
|
||||||
|
}, [onPinClick, onClusterClick]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!map || !markerLibrary) return;
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
|
||||||
|
|
||||||
|
const markers = providers
|
||||||
|
.filter((p) => p.coords && !hiddenIds.has(p.id))
|
||||||
|
.map((p) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const root = createRoot(el);
|
||||||
|
// MapPin's own onClick stays for keyboard a11y (Enter/Space via its
|
||||||
|
// onKeyDown). stopPropagation guards against the DOM click bubbling
|
||||||
|
// to the Map's onClick and closing the popup the same frame it opens.
|
||||||
|
root.render(
|
||||||
|
<MapPin
|
||||||
|
name={p.name}
|
||||||
|
price={p.startingPrice}
|
||||||
|
verified={p.verified}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPinClickRef.current(p.id);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
const marker = new markerLibrary.AdvancedMarkerElement({
|
||||||
|
position: p.coords,
|
||||||
|
content: el,
|
||||||
|
gmpClickable: true,
|
||||||
|
});
|
||||||
|
// Also listen at the Google Maps level + stop the GMaps event so
|
||||||
|
// Map's onClick can't fire when a pin is clicked via mouse. Safe to
|
||||||
|
// fire twice with keyboard — handlePinClick is idempotent.
|
||||||
|
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
|
||||||
|
event.stop();
|
||||||
|
onPinClickRef.current(p.id);
|
||||||
|
});
|
||||||
|
markerToProvider.set(marker, p);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterer = new MarkerClusterer({
|
||||||
|
map,
|
||||||
|
markers,
|
||||||
|
algorithm: new GridAlgorithm({
|
||||||
|
maxZoom: CLUSTER_MAX_ZOOM,
|
||||||
|
gridSize: CLUSTER_GRID_SIZE,
|
||||||
|
}),
|
||||||
|
// Override the library's default "zoom to fit cluster" on click —
|
||||||
|
// we open the cluster popup instead. The event shape the library
|
||||||
|
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
|
||||||
|
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
|
||||||
|
// click doesn't also fire Map.onClick and clear our state.
|
||||||
|
onClusterClick: (event, cluster) => {
|
||||||
|
const anyEvent = event as unknown as {
|
||||||
|
stop?: () => void;
|
||||||
|
stopPropagation?: () => void;
|
||||||
|
domEvent?: { stopPropagation?: () => void };
|
||||||
|
};
|
||||||
|
anyEvent.stop?.();
|
||||||
|
anyEvent.stopPropagation?.();
|
||||||
|
anyEvent.domEvent?.stopPropagation?.();
|
||||||
|
|
||||||
|
const providersInCluster = cluster.markers
|
||||||
|
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||||
|
.filter((p): p is ProviderData => !!p);
|
||||||
|
const clusterPosition =
|
||||||
|
cluster.position instanceof window.google.maps.LatLng
|
||||||
|
? cluster.position.toJSON()
|
||||||
|
: (cluster.position as google.maps.LatLngLiteral);
|
||||||
|
onClusterClickRef.current(providersInCluster, clusterPosition);
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
render: ({ count, position, markers: clusterMarkers }) => {
|
||||||
|
const providersInCluster = clusterMarkers
|
||||||
|
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||||
|
.filter((p): p is ProviderData => !!p);
|
||||||
|
const hasVerified = providersInCluster.some((p) => p.verified);
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const root = createRoot(el);
|
||||||
|
// Visual only — click is handled at the MarkerClusterer level above.
|
||||||
|
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
return new markerLibrary.AdvancedMarkerElement({
|
||||||
|
position,
|
||||||
|
content: el,
|
||||||
|
gmpClickable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clusterer.clearMarkers();
|
||||||
|
// Defer unmount so React doesn't warn about unmounting during render.
|
||||||
|
setTimeout(() => {
|
||||||
|
roots.forEach((r) => r.unmount());
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
}, [map, markerLibrary, providers, hiddenIds]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Empty-state shown when no API key is configured or no providers have coords. */
|
||||||
|
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
|
||||||
|
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
|
Map unavailable
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{reason === 'no-key'
|
||||||
|
? 'Google Maps API key not configured.'
|
||||||
|
: 'No provider locations to display.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Map showing provider pins with clustering + click-to-open popups.
|
||||||
|
*
|
||||||
|
* **Interaction model:**
|
||||||
|
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
|
||||||
|
* coord. Clicking the map background reverts.
|
||||||
|
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
|
||||||
|
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
|
||||||
|
* (13) or below. Zoom in past that and every pin shows individually.
|
||||||
|
* - Clicking a cluster opens a `ClusterPopup` listing its providers
|
||||||
|
* (verified-first). Clicking a row **pans and zooms the map to that
|
||||||
|
* provider's location** (zoom 15 = past the clustering ceiling, so the
|
||||||
|
* other cluster members separate into their own pins around the selected
|
||||||
|
* one) and opens that provider's `MapPopup`. The cluster state is cleared
|
||||||
|
* — there's no back-to-list; the user's path forward is clear rather than
|
||||||
|
* hierarchical.
|
||||||
|
*
|
||||||
|
* **Viewport:** auto-fits to include every provider with coords on load and
|
||||||
|
* when the list changes. Single-provider maps centre with zoom 13.
|
||||||
|
*
|
||||||
|
* **Empty states:** if no API key is set or no providers have coords, a
|
||||||
|
* subtle empty state renders in place (no throw).
|
||||||
|
*
|
||||||
|
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
|
||||||
|
* (molecules). Clustering via `@googlemaps/markerclusterer`.
|
||||||
|
*/
|
||||||
|
export const ProviderMap = React.forwardRef<ProviderMapHandle, ProviderMapProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
providers,
|
||||||
|
selectedProviderId,
|
||||||
|
onSelectProvider,
|
||||||
|
defaultCenter = FALLBACK_CENTER,
|
||||||
|
defaultZoom = FALLBACK_ZOOM,
|
||||||
|
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
||||||
|
externalisePopups = false,
|
||||||
|
onActiveChange,
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
|
||||||
|
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
|
||||||
|
const [exiting, setExiting] = React.useState(false);
|
||||||
|
const mapRef = React.useRef<google.maps.Map | null>(null);
|
||||||
|
const exitTimerRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Helper: cancel any pending exit timer so rapid clicks don't clobber
|
||||||
|
// newly-opened popups with a leftover clear from a previous close.
|
||||||
|
const cancelExit = React.useCallback(() => {
|
||||||
|
if (exitTimerRef.current) {
|
||||||
|
window.clearTimeout(exitTimerRef.current);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setExiting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
|
||||||
|
|
||||||
|
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
|
||||||
|
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
|
||||||
|
|
||||||
|
const activeProvider = React.useMemo(
|
||||||
|
() =>
|
||||||
|
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
|
||||||
|
[withCoords, effectiveProviderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pins hidden from the map (because their popup is showing instead).
|
||||||
|
const hiddenIds = React.useMemo(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
if (effectiveProviderId) s.add(effectiveProviderId);
|
||||||
|
if (activeCluster) {
|
||||||
|
activeCluster.providers.forEach((p) => s.add(p.id));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}, [effectiveProviderId, activeCluster]);
|
||||||
|
|
||||||
|
const handlePinClick = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
cancelExit();
|
||||||
|
setActiveProviderId(id);
|
||||||
|
setActiveCluster(null);
|
||||||
|
},
|
||||||
|
[cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterClick = React.useCallback(
|
||||||
|
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
|
||||||
|
cancelExit();
|
||||||
|
setActiveProviderId(null);
|
||||||
|
setActiveCluster({ providers: clusterProviders, position });
|
||||||
|
},
|
||||||
|
[cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Shared close path — animate the popup out (exiting=true triggers the
|
||||||
|
* CSS transition in MapPopup / ClusterPopup), then actually clear state
|
||||||
|
* after the transition completes so the pin can fade back in. */
|
||||||
|
const closeWithExit = React.useCallback(() => {
|
||||||
|
if (!activeProviderId && !activeCluster) return;
|
||||||
|
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||||
|
setExiting(true);
|
||||||
|
exitTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActiveProviderId(null);
|
||||||
|
setActiveCluster(null);
|
||||||
|
setExiting(false);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}, POPUP_EXIT_MS);
|
||||||
|
}, [activeProviderId, activeCluster]);
|
||||||
|
|
||||||
|
const handleMapClick = closeWithExit;
|
||||||
|
const handleCloseCluster = closeWithExit;
|
||||||
|
|
||||||
|
// Emit active-state changes when the caller is rendering popups externally.
|
||||||
|
const onActiveChangeRef = React.useRef(onActiveChange);
|
||||||
|
React.useEffect(() => {
|
||||||
|
onActiveChangeRef.current = onActiveChange;
|
||||||
|
}, [onActiveChange]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
onActiveChangeRef.current?.({
|
||||||
|
provider: activeProvider,
|
||||||
|
cluster: activeCluster
|
||||||
|
? {
|
||||||
|
providers: activeCluster.providers,
|
||||||
|
position: {
|
||||||
|
lat: activeCluster.position.lat,
|
||||||
|
lng: activeCluster.position.lng,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
exiting,
|
||||||
|
});
|
||||||
|
}, [activeProvider, activeCluster, exiting]);
|
||||||
|
|
||||||
|
/** Cluster list → single-provider drill-in.
|
||||||
|
* Pans + zooms the map to the provider's coords (zoom 15 = past
|
||||||
|
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
|
||||||
|
* pins around the selected one), then clears the cluster state and
|
||||||
|
* opens the single-provider popup. */
|
||||||
|
const handleDrillIntoProvider = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
cancelExit();
|
||||||
|
const provider = withCoords.find((p) => p.id === id);
|
||||||
|
if (provider?.coords && mapRef.current) {
|
||||||
|
mapRef.current.panTo(provider.coords);
|
||||||
|
mapRef.current.setZoom(DRILL_IN_ZOOM);
|
||||||
|
}
|
||||||
|
setActiveProviderId(id);
|
||||||
|
setActiveCluster(null);
|
||||||
|
},
|
||||||
|
[withCoords, cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Imperative handle for external callers (drawer close, cluster-row tap).
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
clearActive: closeWithExit,
|
||||||
|
drillIntoProvider: handleDrillIntoProvider,
|
||||||
|
}),
|
||||||
|
[closeWithExit, handleDrillIntoProvider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootSx = [
|
||||||
|
{
|
||||||
|
position: 'relative' as const,
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 300,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'var(--fa-color-surface-cool)',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Empty states
|
||||||
|
if (!apiKey) {
|
||||||
|
return (
|
||||||
|
<Box role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<MapEmptyState reason="no-key" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<MapEmptyState reason="no-coords" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<APIProvider apiKey={apiKey}>
|
||||||
|
<GoogleMap
|
||||||
|
defaultCenter={defaultCenter}
|
||||||
|
defaultZoom={defaultZoom}
|
||||||
|
mapId={MAP_ID}
|
||||||
|
disableDefaultUI
|
||||||
|
zoomControl
|
||||||
|
gestureHandling="greedy"
|
||||||
|
onClick={handleMapClick}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<FitBounds providers={withCoords} />
|
||||||
|
<MapRefCapture mapRef={mapRef} />
|
||||||
|
|
||||||
|
<MarkerLayer
|
||||||
|
providers={withCoords}
|
||||||
|
hiddenIds={hiddenIds}
|
||||||
|
onPinClick={handlePinClick}
|
||||||
|
onClusterClick={handleClusterClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Internal popups — skipped when caller externalises them (e.g.
|
||||||
|
mobile drawer). Active state still flows via onActiveChange. */}
|
||||||
|
{!externalisePopups && activeProvider && (
|
||||||
|
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
|
||||||
|
<MapPopup
|
||||||
|
name={activeProvider.name}
|
||||||
|
imageUrl={activeProvider.imageUrl}
|
||||||
|
price={activeProvider.startingPrice}
|
||||||
|
location={activeProvider.location}
|
||||||
|
rating={activeProvider.rating}
|
||||||
|
verified={activeProvider.verified}
|
||||||
|
exiting={exiting}
|
||||||
|
onClick={() => onSelectProvider(activeProvider.id)}
|
||||||
|
/>
|
||||||
|
</AdvancedMarker>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster list popup — shown while a cluster is active and no
|
||||||
|
provider has been drilled into. Drilling clears activeCluster,
|
||||||
|
which swaps this for the single-provider popup above. */}
|
||||||
|
{!externalisePopups && activeCluster && !activeProviderId && (
|
||||||
|
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
|
||||||
|
<ClusterPopup
|
||||||
|
providers={activeCluster.providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
location: p.location,
|
||||||
|
verified: p.verified,
|
||||||
|
rating: p.rating,
|
||||||
|
startingPrice: p.startingPrice,
|
||||||
|
}))}
|
||||||
|
exiting={exiting}
|
||||||
|
onSelectProvider={handleDrillIntoProvider}
|
||||||
|
onClose={handleCloseCluster}
|
||||||
|
/>
|
||||||
|
</AdvancedMarker>
|
||||||
|
)}
|
||||||
|
</GoogleMap>
|
||||||
|
</APIProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderMap.displayName = 'ProviderMap';
|
||||||
|
export default ProviderMap;
|
||||||
6
src/components/organisms/ProviderMap/index.ts
Normal file
6
src/components/organisms/ProviderMap/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
ProviderMap,
|
||||||
|
type ProviderMapProps,
|
||||||
|
type ProviderMapHandle,
|
||||||
|
type ProviderMapActiveState,
|
||||||
|
} from './ProviderMap';
|
||||||
@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
|
|||||||
name: 'Everyday Funeral Package',
|
name: 'Everyday Funeral Package',
|
||||||
price: 5495.45,
|
price: 5495.45,
|
||||||
provider: {
|
provider: {
|
||||||
name: 'Mackay Family Funerals',
|
name: 'Mackay Family Funeral Directors & Cremation Services',
|
||||||
location: 'Inglewood',
|
location: 'Inglewood',
|
||||||
logoUrl: DEMO_LOGO,
|
logoUrl: DEMO_LOGO,
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
import {
|
||||||
|
ComparisonTable,
|
||||||
|
COMPARISON_TABLE_COL_WIDTH,
|
||||||
|
type ComparisonPackage,
|
||||||
|
} from '../../organisms/ComparisonTable';
|
||||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
||||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
||||||
|
|
||||||
@@ -113,27 +120,147 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Natural table width = (row-label col) + (pkg col × n), matches page header maxWidth.
|
||||||
|
// Page header container reaches this same width so the table's left edge aligns
|
||||||
|
// with the page header's left edge when the table overflows horizontally.
|
||||||
|
const tableNaturalWidth = COMPARISON_TABLE_COL_WIDTH * (allPackages.length + 1);
|
||||||
|
const pageMaxWidth = COMPARISON_TABLE_COL_WIDTH * 4; // fits 3-package case flush
|
||||||
|
|
||||||
|
// Matching horizontal padding between the page header container and the
|
||||||
|
// table-zone spacers keeps inner-content left edges aligned on all viewports.
|
||||||
|
const edgePadding = { xs: 16, md: 24 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} sx={sx}>
|
<Box ref={ref} sx={sx}>
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="wide-form"
|
variant={isMobile ? 'wide-form' : 'bleed'}
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
showBackLink
|
showBackLink={isMobile}
|
||||||
backLabel="Back"
|
backLabel="Back"
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
>
|
>
|
||||||
{/* Page header with Share/Print actions */}
|
{!isMobile && (
|
||||||
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
<>
|
||||||
<Box
|
{/* Page header zone — centred, bounded to the table's natural width */}
|
||||||
sx={{
|
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||||
display: 'flex',
|
<Box
|
||||||
alignItems: 'flex-start',
|
sx={{
|
||||||
justifyContent: 'space-between',
|
width: '100%',
|
||||||
gap: 2,
|
maxWidth: pageMaxWidth,
|
||||||
flexWrap: 'wrap',
|
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
|
||||||
}}
|
pt: { xs: 2, md: 3 },
|
||||||
>
|
pb: { xs: 3, md: 5 },
|
||||||
<Box>
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
onClick={onBack}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
mb: 2,
|
||||||
|
'&:hover': { color: 'text.primary' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
||||||
|
Compare packages
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" aria-live="polite">
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(onShare || onPrint) && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||||
|
{onShare && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
startIcon={<ShareOutlinedIcon />}
|
||||||
|
onClick={onShare}
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onPrint && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PrintOutlinedIcon />}
|
||||||
|
onClick={onPrint}
|
||||||
|
>
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Table zone — width-matching spacers centre the table when room
|
||||||
|
allows, collapse to the minimum when table is wider than
|
||||||
|
viewport so overflow extends rightward from the page's
|
||||||
|
content column. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
width: 'max-content',
|
||||||
|
minWidth: '100%',
|
||||||
|
py: { xs: 3, md: 5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ flexShrink: 0, width: tableNaturalWidth }}>
|
||||||
|
<ComparisonTable
|
||||||
|
packages={allPackages}
|
||||||
|
onArrange={onArrange}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: Tab rail + card view */}
|
||||||
|
{isMobile && allPackages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
||||||
Compare packages
|
Compare packages
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -142,50 +269,21 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Share + Print */}
|
<Divider sx={{ mb: 3 }} />
|
||||||
{(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 */}
|
<Typography
|
||||||
{!isMobile && (
|
id="comparison-rail-heading"
|
||||||
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
variant="label"
|
||||||
)}
|
component="h2"
|
||||||
|
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
|
||||||
{/* Mobile: Tab rail + card view */}
|
>
|
||||||
{isMobile && allPackages.length > 0 && (
|
Choose a package to view
|
||||||
<>
|
</Typography>
|
||||||
{/* Tab rail — mini cards showing provider + package + price */}
|
|
||||||
<Box
|
<Box
|
||||||
ref={railRef}
|
ref={railRef}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
id={tablistId}
|
id={tablistId}
|
||||||
aria-label="Packages to compare"
|
aria-labelledby="comparison-rail-heading"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
@@ -193,8 +291,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
|||||||
py: 2,
|
py: 2,
|
||||||
px: 2,
|
px: 2,
|
||||||
mx: -2,
|
mx: -2,
|
||||||
mt: 1,
|
mb: 1.5,
|
||||||
mb: 3,
|
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
@@ -216,6 +313,54 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Dot indicator — position + count. Purely visual supplement;
|
||||||
|
the tab rail above is the accessible navigation, so dots
|
||||||
|
are aria-hidden and skipped by keyboard tab-order. */}
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allPackages.map((_, idx) => {
|
||||||
|
const isActive = idx === activeTabIdx;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => handleTabClick(idx)}
|
||||||
|
sx={{
|
||||||
|
appearance: 'none',
|
||||||
|
border: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
p: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
'& > span': {
|
||||||
|
display: 'block',
|
||||||
|
width: isActive ? 24 : 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: isActive
|
||||||
|
? 'var(--fa-color-brand-600)'
|
||||||
|
: 'var(--fa-color-neutral-300)',
|
||||||
|
transition: 'width 0.2s ease, background-color 0.2s ease',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{activePackage && (
|
{activePackage && (
|
||||||
<Box
|
<Box
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { PackagesStep } from './PackagesStep';
|
|
||||||
import type { PackageData, PackagesStepProvider } from './PackagesStep';
|
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import { PackagesStep } from './PackagesStep';
|
||||||
|
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep';
|
||||||
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -35,10 +35,19 @@ const nav = (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const mockProvider: PackagesStepProvider = {
|
// ─── Mock data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const verifiedProvider: PackagesStepProvider = {
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const unverifiedProvider: PackagesStepProvider = {
|
||||||
name: 'H.Parsons Funeral Directors',
|
name: 'H.Parsons Funeral Directors',
|
||||||
location: 'Wentworth, NSW',
|
location: 'Wentworth, NSW',
|
||||||
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
|
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
};
|
};
|
||||||
@@ -147,6 +156,119 @@ const otherPackages: PackageData[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const manyOtherPackages: PackageData[] = [
|
||||||
|
...otherPackages,
|
||||||
|
{
|
||||||
|
id: 'memorial',
|
||||||
|
name: 'Memorial Service',
|
||||||
|
price: 2400,
|
||||||
|
description: 'A celebration-of-life service without burial or cremation on the same day.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Professional Service Fee', price: 1200 },
|
||||||
|
{ name: 'Venue coordination', price: 600 },
|
||||||
|
{ name: 'Memorial book', price: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'graveside',
|
||||||
|
name: 'Graveside Service',
|
||||||
|
price: 2900,
|
||||||
|
description: 'A simple graveside committal, ideal for smaller family gatherings.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||||
|
{ name: 'Professional Service Fee', price: 1100 },
|
||||||
|
{ name: 'Cemetery coordination', price: 400 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prepaid-basic',
|
||||||
|
name: 'Prepaid Basic Plan',
|
||||||
|
price: 3600,
|
||||||
|
description: 'Lock in today’s price for a basic cremation package, paid over 12 months.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Locked-in pricing', price: 0, priceLabel: 'Complimentary' },
|
||||||
|
{ name: 'Professional Service Fee', price: 1200 },
|
||||||
|
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3600,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nearbyVerifiedProviders: NearbyVerifiedProvider[] = [
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funerals',
|
||||||
|
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||||
|
location: 'Warrawong, NSW',
|
||||||
|
startingPrice: 2450,
|
||||||
|
rating: 4.8,
|
||||||
|
reviewCount: 23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mannings',
|
||||||
|
name: 'Mannings Funerals',
|
||||||
|
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||||
|
location: 'Bega, NSW',
|
||||||
|
startingPrice: 1950,
|
||||||
|
rating: 4.7,
|
||||||
|
reviewCount: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'killick',
|
||||||
|
name: 'Killick Family Funerals',
|
||||||
|
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||||
|
location: 'Kingaroy, QLD',
|
||||||
|
startingPrice: 3100,
|
||||||
|
rating: 4.9,
|
||||||
|
reviewCount: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mackay',
|
||||||
|
name: 'Mackay Family Funerals',
|
||||||
|
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||||
|
location: 'Ourimbah, NSW',
|
||||||
|
startingPrice: 2780,
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 19,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tier2Packages: PackageData[] = [
|
||||||
|
{
|
||||||
|
id: 't2-standard',
|
||||||
|
name: 'Standard Funeral Service',
|
||||||
|
price: 5200,
|
||||||
|
description:
|
||||||
|
'A full-service package based on publicly available information. Breakdown not available — make an enquiry to confirm what is included.',
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't2-basic',
|
||||||
|
name: 'Basic Cremation',
|
||||||
|
price: 3400,
|
||||||
|
description:
|
||||||
|
'An entry-level package based on publicly available information. Pricing is indicative only.',
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const meta: Meta<typeof PackagesStep> = {
|
const meta: Meta<typeof PackagesStep> = {
|
||||||
@@ -161,45 +283,24 @@ const meta: Meta<typeof PackagesStep> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof PackagesStep>;
|
type Story = StoryObj<typeof PackagesStep>;
|
||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
// ─── Verified ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
/** Verified provider — matching packages + up to 3 other packages from the same provider */
|
||||||
export const Default: Story = {
|
export const Verified: Story = {
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PackagesStep
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
otherPackages={otherPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── With selection ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Package already selected — detail panel visible */
|
|
||||||
export const WithSelection: Story = {
|
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
otherPackages={otherPackages}
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -207,21 +308,127 @@ export const WithSelection: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── No other packages (all match) ─────────────────────────────────────────
|
// ─── Verified — with "See all" link ─────────────────────────────────────────
|
||||||
|
|
||||||
/** All packages match filters — no "Other packages" section */
|
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
|
||||||
export const AllMatching: Story = {
|
export const VerifiedWithManyOtherPackages: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
|
packages={matchedPackages}
|
||||||
|
secondaryList={{ kind: 'same-provider-more', packages: manyOtherPackages }}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
|
onSeeAllPackages={() => alert('Route to showAllFromProvider variant')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── "Show all from provider" variant ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Flat "All packages from [Provider]" view — no grouping, selected package preserved */
|
||||||
|
export const AllFromProvider: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
const allPackages = [...matchedPackages, ...manyOtherPackages];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
|
packages={allPackages}
|
||||||
|
showAllFromProvider
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tier 3 (itemised breakdown) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Tier 3 unverified — itemised breakdown + "Make an enquiry" + nearby verified alternatives */
|
||||||
|
export const Tier3: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={unverifiedProvider}
|
||||||
|
providerTier="tier3"
|
||||||
|
packages={matchedPackages}
|
||||||
|
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Make an enquiry')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tier 2 (price only, no breakdown) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** Tier 2 unverified — price only, detail panel shows "Itemised Pricing Unavailable" */
|
||||||
|
export const Tier2: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('t2-standard');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={unverifiedProvider}
|
||||||
|
providerTier="tier2"
|
||||||
|
packages={tier2Packages}
|
||||||
|
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Make an enquiry')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** No selection yet — empty detail panel */
|
||||||
|
export const NoSelection: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
packages={[...matchedPackages, ...otherPackages]}
|
providerTier="verified"
|
||||||
|
packages={matchedPackages}
|
||||||
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -229,7 +436,27 @@ export const AllMatching: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
/** Verified provider with no "other packages" — primary list only */
|
||||||
|
export const VerifiedNoSecondary: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
|
packages={matchedPackages}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** Pre-planning flow — softer copy */
|
/** Pre-planning flow — softer copy */
|
||||||
export const PrePlanning: Story = {
|
export const PrePlanning: Story = {
|
||||||
@@ -238,13 +465,15 @@ export const PrePlanning: Story = {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
otherPackages={otherPackages}
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
isPrePlanning
|
isPrePlanning
|
||||||
@@ -253,16 +482,15 @@ export const PrePlanning: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Validation error ───────────────────────────────────────────────────────
|
/** Validation error */
|
||||||
|
|
||||||
/** Error shown when no package selected */
|
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
|
|||||||
@@ -1,68 +1,125 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||||
|
import { MiniCard } from '../../molecules/MiniCard';
|
||||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
import { PackageDetail } from '../../organisms/PackageDetail';
|
||||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Divider } from '../../atoms/Divider';
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
|
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
export type {
|
||||||
|
PackageData,
|
||||||
|
PackagesStepProvider,
|
||||||
|
NearbyVerifiedProvider,
|
||||||
|
ProviderTier,
|
||||||
|
SecondaryList,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/** Provider summary for the compact card */
|
// ─── Tier copy map ───────────────────────────────────────────────────────────
|
||||||
export interface PackagesStepProvider {
|
|
||||||
/** Provider name */
|
interface TierCopy {
|
||||||
name: string;
|
heading: string;
|
||||||
/** Location */
|
subheading: (isPrePlanning: boolean) => string;
|
||||||
location: string;
|
arrangeLabel: string;
|
||||||
/** Image URL */
|
priceDisclaimer?: string;
|
||||||
imageUrl?: string;
|
itemizedUnavailable: boolean;
|
||||||
/** Rating */
|
emptyDetailMessage: string;
|
||||||
rating?: number;
|
|
||||||
/** Review count */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package data for the selection list */
|
const TIER_COPY: Record<ProviderTier, TierCopy> = {
|
||||||
export interface PackageData {
|
verified: {
|
||||||
/** Unique package ID */
|
heading: 'Choose a funeral package',
|
||||||
id: string;
|
subheading: (isPrePlanning) =>
|
||||||
/** Package display name */
|
isPrePlanning
|
||||||
name: string;
|
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||||
/** Package price in dollars */
|
: 'Each package includes a set of services. You can customise your selections in the next steps.',
|
||||||
price: number;
|
arrangeLabel: 'Make Arrangement',
|
||||||
/** Short description */
|
itemizedUnavailable: false,
|
||||||
description?: string;
|
emptyDetailMessage: "Select a package to see what's included.",
|
||||||
/** Line item sections for the detail panel */
|
},
|
||||||
sections: PackageSection[];
|
tier3: {
|
||||||
/** Total price (may differ from base price with extras) */
|
heading: 'Explore available packages',
|
||||||
total?: number;
|
subheading: (isPrePlanning) =>
|
||||||
/** Extra items section (after total) */
|
isPrePlanning
|
||||||
extras?: PackageSection;
|
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
||||||
/** Terms and conditions */
|
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
|
||||||
terms?: string;
|
arrangeLabel: 'Make an enquiry',
|
||||||
}
|
priceDisclaimer:
|
||||||
|
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||||
|
itemizedUnavailable: false,
|
||||||
|
emptyDetailMessage: "Select a package to see what's included.",
|
||||||
|
},
|
||||||
|
tier2: {
|
||||||
|
heading: 'Explore available packages',
|
||||||
|
subheading: (isPrePlanning) =>
|
||||||
|
isPrePlanning
|
||||||
|
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
||||||
|
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
|
||||||
|
arrangeLabel: 'Make an enquiry',
|
||||||
|
priceDisclaimer:
|
||||||
|
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||||
|
itemizedUnavailable: true,
|
||||||
|
emptyDetailMessage: 'Select a package to see more details.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show at most this many "other packages from this provider" inline before
|
||||||
|
// switching to "top N + See all →" behaviour.
|
||||||
|
const SAME_PROVIDER_INLINE_LIMIT = 3;
|
||||||
|
|
||||||
|
// Max number of verified provider MiniCards in the "Similar packages from
|
||||||
|
// verified providers" grid on unverified pages.
|
||||||
|
const NEARBY_VERIFIED_LIMIT = 4;
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Props for the PackagesStep page component */
|
|
||||||
export interface PackagesStepProps {
|
export interface PackagesStepProps {
|
||||||
/** Provider summary shown at top of the list panel */
|
/** Provider shown at the top of the list panel */
|
||||||
provider: PackagesStepProvider;
|
provider: PackagesStepProvider;
|
||||||
/** Packages matching the user's filters from the previous step */
|
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */
|
||||||
|
providerTier: ProviderTier;
|
||||||
|
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
|
||||||
packages: PackageData[];
|
packages: PackageData[];
|
||||||
/** Other packages from this provider that didn't match filters (shown in secondary group) */
|
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
|
||||||
otherPackages?: PackageData[];
|
secondaryList?: SecondaryList;
|
||||||
/** Currently selected package ID */
|
/** Currently selected package ID */
|
||||||
selectedPackageId: string | null;
|
selectedPackageId: string | null;
|
||||||
/** Callback when a package is selected */
|
/** Callback when a primary-list package is selected (or cleared via mobile back) */
|
||||||
onSelectPackage: (id: string) => void;
|
onSelectPackage: (id: string | null) => void;
|
||||||
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
|
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
|
||||||
onArrange: () => void;
|
onArrange: () => void;
|
||||||
/** Callback when the provider card is clicked (opens provider profile popup) */
|
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
|
||||||
|
onCompare?: () => void;
|
||||||
|
/** Whether the currently-selected package is already in the comparison
|
||||||
|
* basket. When true, PackageDetail swaps its Compare button into the
|
||||||
|
* "In comparison" selected-state (inert; removal via CompareBar). */
|
||||||
|
isSelectedPackageInCart?: boolean;
|
||||||
|
/** Callback when a nearby-verified provider card is clicked (route change to that provider's PackagesStep) */
|
||||||
|
onNearbyProviderClick?: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Callback when "See all N packages from [Provider]" is clicked.
|
||||||
|
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
|
||||||
|
* Only used when secondaryList.kind === 'same-provider-more' and list length > 3.
|
||||||
|
*/
|
||||||
|
onSeeAllPackages?: () => void;
|
||||||
|
/** Callback when the provider card is clicked (future: opens provider profile) */
|
||||||
onProviderClick?: () => void;
|
onProviderClick?: () => void;
|
||||||
/** Callback for the Back button */
|
/** Callback for the Back button */
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
/**
|
||||||
|
* When true, renders the "All packages from [Provider]" variant:
|
||||||
|
* flat list, no grouping, no secondary list, no "Matching your preferences" heading.
|
||||||
|
* Caller passes the full package list in `packages`.
|
||||||
|
*/
|
||||||
|
showAllFromProvider?: boolean;
|
||||||
/** Validation error */
|
/** Validation error */
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Whether the arrange action is loading */
|
/** Whether the arrange action is loading */
|
||||||
@@ -75,23 +132,61 @@ export interface PackagesStepProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Accent bar + label — used for both "Matching your preferences" and "Other packages from [X]". */
|
||||||
|
function GroupHeading({
|
||||||
|
label,
|
||||||
|
emphasis = 'primary',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
emphasis?: 'primary' | 'secondary';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 3,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: emphasis === 'primary' ? 'primary.main' : 'text.secondary',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: emphasis === 'primary' ? 'text.primary' : 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3 — Package selection page for the FA arrangement wizard.
|
* Package selection step — tier-aware, unified page component.
|
||||||
*
|
*
|
||||||
* List + Detail split layout. Left panel shows the selected provider
|
* Handles all three provider tiers (verified, tier3, tier2) via the
|
||||||
* (compact) and selectable package cards. Right panel shows the full
|
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
|
||||||
* detail breakdown of the selected package with "Make Arrangement" CTA.
|
* itemised-unavailable state are derived from tier.
|
||||||
*
|
*
|
||||||
* Packages are split into two groups:
|
* Left column layout varies by `secondaryList`:
|
||||||
* - **Matching your preferences**: packages that matched the user's filters
|
* - `same-provider-more` (verified): primary "Matching your preferences"
|
||||||
* from the providers step
|
* list + "Other packages from [Provider]" list. If >3 other packages,
|
||||||
* - **Other packages from [Provider]**: remaining packages outside those
|
* shows top 3 + "See all N packages from [Provider] →" link that routes
|
||||||
* filters, shown below a divider for passive discovery
|
* to the same page with `showAllFromProvider`.
|
||||||
|
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
|
||||||
|
* from verified providers" 2-column MiniCard grid, capped at 4. Every
|
||||||
|
* card is verified by definition.
|
||||||
*
|
*
|
||||||
* Selecting a package reveals its detail. Clicking "Make Arrangement"
|
* When `showAllFromProvider` is true, renders a flat "All packages from
|
||||||
* on the detail panel triggers the ArrangementDialog (D-E).
|
* [Provider]" list with no grouping and no secondary list. The caller
|
||||||
|
* preserves `selectedPackageId` across this navigation.
|
||||||
*
|
*
|
||||||
* Pure presentation component — props in, callbacks out.
|
* Pure presentation component — props in, callbacks out.
|
||||||
*
|
*
|
||||||
@@ -99,191 +194,290 @@ export interface PackagesStepProps {
|
|||||||
*/
|
*/
|
||||||
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||||
provider,
|
provider,
|
||||||
|
providerTier,
|
||||||
packages,
|
packages,
|
||||||
otherPackages = [],
|
secondaryList,
|
||||||
selectedPackageId,
|
selectedPackageId,
|
||||||
onSelectPackage,
|
onSelectPackage,
|
||||||
onArrange,
|
onArrange,
|
||||||
|
onCompare,
|
||||||
|
isSelectedPackageInCart = false,
|
||||||
|
onNearbyProviderClick,
|
||||||
|
onSeeAllPackages,
|
||||||
onProviderClick,
|
onProviderClick,
|
||||||
onBack,
|
onBack,
|
||||||
|
showAllFromProvider = false,
|
||||||
error,
|
error,
|
||||||
loading = false,
|
loading = false,
|
||||||
navigation,
|
navigation,
|
||||||
isPrePlanning = false,
|
isPrePlanning = false,
|
||||||
sx,
|
sx,
|
||||||
}) => {
|
}) => {
|
||||||
const allPackages = [...packages, ...otherPackages];
|
const copy = TIER_COPY[providerTier];
|
||||||
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
|
// Look up the selected package across BOTH the primary list and the
|
||||||
const hasOtherPackages = otherPackages.length > 0;
|
// same-provider-more secondary list — tapping "Premium Funeral Service"
|
||||||
|
// in the "Other packages from X" section should surface its detail too.
|
||||||
|
const selectedPackage =
|
||||||
|
packages.find((p) => p.id === selectedPackageId) ??
|
||||||
|
(secondaryList?.kind === 'same-provider-more'
|
||||||
|
? secondaryList.packages.find((p) => p.id === selectedPackageId)
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const subheading = isPrePlanning
|
// Mobile drill-in: on mobile, the list is the default view — only when the
|
||||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
// user explicitly taps a package do we swap in the detail panel. This
|
||||||
: 'Each package includes a set of services. You can customise your selections in the next steps.';
|
// distinguishes "parent pre-selected first package for desktop auto-display"
|
||||||
|
// (which should NOT jump to detail on mobile) from "user tapped a package".
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const [hasDrilledIn, setHasDrilledIn] = useState(false);
|
||||||
|
const mobileShowDetail = isMobile && hasDrilledIn && selectedPackageId != null;
|
||||||
|
|
||||||
|
const handleSelectPackage = (id: string | null) => {
|
||||||
|
setHasDrilledIn(id != null);
|
||||||
|
onSelectPackage(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
|
}, [mobileShowDetail]);
|
||||||
|
|
||||||
|
const handleLayoutBack = mobileShowDetail ? () => handleSelectPackage(null) : onBack;
|
||||||
|
const layoutBackLabel = mobileShowDetail ? 'Back to packages' : 'Back';
|
||||||
|
|
||||||
|
// Secondary list suppressed in "show all" mode.
|
||||||
|
const activeSecondaryList = showAllFromProvider ? undefined : secondaryList;
|
||||||
|
const hasSecondary = Boolean(activeSecondaryList);
|
||||||
|
|
||||||
|
// For same-provider-more, show top N inline; surface "See all" when over limit.
|
||||||
|
const sameProviderPackages =
|
||||||
|
activeSecondaryList?.kind === 'same-provider-more' ? activeSecondaryList.packages : [];
|
||||||
|
const sameProviderOverflow = sameProviderPackages.length > SAME_PROVIDER_INLINE_LIMIT;
|
||||||
|
const sameProviderVisible = sameProviderOverflow
|
||||||
|
? sameProviderPackages.slice(0, SAME_PROVIDER_INLINE_LIMIT)
|
||||||
|
: sameProviderPackages;
|
||||||
|
|
||||||
|
const heading = showAllFromProvider ? `All packages from ${provider.name}` : copy.heading;
|
||||||
|
const subheading = showAllFromProvider
|
||||||
|
? `Every package ${provider.name} offers, including those outside your preferences.`
|
||||||
|
: copy.subheading(isPrePlanning);
|
||||||
|
|
||||||
|
const primaryListAriaLabel = showAllFromProvider
|
||||||
|
? `All packages from ${provider.name}`
|
||||||
|
: 'Funeral packages';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="list-detail"
|
variant="list-detail"
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
showBackLink
|
showBackLink
|
||||||
backLabel="Back"
|
backLabel={layoutBackLabel}
|
||||||
onBack={onBack}
|
onBack={handleLayoutBack}
|
||||||
sx={sx}
|
sx={sx}
|
||||||
secondaryPanel={
|
secondaryPanel={
|
||||||
selectedPackage ? (
|
|
||||||
<PackageDetail
|
|
||||||
name={selectedPackage.name}
|
|
||||||
price={selectedPackage.price}
|
|
||||||
sections={selectedPackage.sections}
|
|
||||||
total={selectedPackage.total}
|
|
||||||
extras={selectedPackage.extras}
|
|
||||||
terms={selectedPackage.terms}
|
|
||||||
onArrange={onArrange}
|
|
||||||
arrangeDisabled={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: 300,
|
|
||||||
bgcolor: 'var(--fa-color-brand-50)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
Select a package to see what's included.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Provider compact card — clickable to open provider profile */}
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<ProviderCardCompact
|
|
||||||
name={provider.name}
|
|
||||||
location={provider.location}
|
|
||||||
imageUrl={provider.imageUrl}
|
|
||||||
rating={provider.rating}
|
|
||||||
reviewCount={provider.reviewCount}
|
|
||||||
onClick={onProviderClick}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Heading */}
|
|
||||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
|
||||||
Choose a funeral package
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
{subheading}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── Matching packages ─── */}
|
|
||||||
{hasOtherPackages && (
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: {
|
||||||
alignItems: 'center',
|
xs: mobileShowDetail ? 'block' : 'none',
|
||||||
gap: 1.5,
|
md: 'block',
|
||||||
mb: 2,
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{selectedPackage ? (
|
||||||
sx={{
|
<PackageDetail
|
||||||
width: 3,
|
name={selectedPackage.name}
|
||||||
height: 20,
|
price={selectedPackage.price}
|
||||||
borderRadius: 1,
|
sections={selectedPackage.sections}
|
||||||
bgcolor: 'primary.main',
|
total={selectedPackage.total}
|
||||||
flexShrink: 0,
|
extras={selectedPackage.extras}
|
||||||
}}
|
terms={selectedPackage.terms}
|
||||||
/>
|
onArrange={onArrange}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
onCompare={onCompare}
|
||||||
Matching your preferences
|
inCart={isSelectedPackageInCart}
|
||||||
</Typography>
|
arrangeDisabled={loading}
|
||||||
</Box>
|
arrangeLabel={copy.arrangeLabel}
|
||||||
)}
|
priceDisclaimer={copy.priceDisclaimer}
|
||||||
|
itemizedUnavailable={copy.itemizedUnavailable}
|
||||||
<Box
|
/>
|
||||||
role="radiogroup"
|
) : (
|
||||||
aria-label="Funeral packages"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
|
||||||
>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<ServiceOption
|
|
||||||
key={pkg.id}
|
|
||||||
name={pkg.name}
|
|
||||||
description={pkg.description}
|
|
||||||
price={pkg.price}
|
|
||||||
selected={selectedPackageId === pkg.id}
|
|
||||||
onClick={() => onSelectPackage(pkg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{packages.length === 0 && (
|
|
||||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No packages match your current preferences.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ─── Other packages (passive discovery) ─── */}
|
|
||||||
{hasOtherPackages && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 3,
|
display: 'flex',
|
||||||
height: 20,
|
alignItems: 'center',
|
||||||
borderRadius: 1,
|
justifyContent: 'center',
|
||||||
bgcolor: 'text.secondary',
|
height: '100%',
|
||||||
flexShrink: 0,
|
minHeight: 300,
|
||||||
|
bgcolor: 'var(--fa-color-brand-50)',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
Other packages from {provider.name}
|
{copy.emptyDetailMessage}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
)}
|
||||||
role="radiogroup"
|
</Box>
|
||||||
aria-label={`Other packages from ${provider.name}`}
|
}
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
|
>
|
||||||
|
{/* List column — hidden on mobile when a package is selected (drill-in) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: {
|
||||||
|
xs: mobileShowDetail ? 'none' : 'block',
|
||||||
|
md: 'block',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Provider compact card */}
|
||||||
|
<Box sx={{ mb: 6 }}>
|
||||||
|
<ProviderCardCompact
|
||||||
|
name={provider.name}
|
||||||
|
location={provider.location}
|
||||||
|
imageUrl={provider.imageUrl}
|
||||||
|
rating={provider.rating}
|
||||||
|
reviewCount={provider.reviewCount}
|
||||||
|
onClick={onProviderClick}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Heading + subheading */}
|
||||||
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
|
{heading}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
|
||||||
|
{subheading}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
{otherPackages.map((pkg) => (
|
{error}
|
||||||
<ServiceOption
|
</Typography>
|
||||||
key={pkg.id}
|
)}
|
||||||
name={pkg.name}
|
|
||||||
description={pkg.description}
|
{/* ─── Primary packages ─── */}
|
||||||
price={pkg.price}
|
{/* Show "Matching your preferences" heading only when a secondary list follows */}
|
||||||
selected={selectedPackageId === pkg.id}
|
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
|
||||||
onClick={() => onSelectPackage(pkg.id)}
|
|
||||||
/>
|
<Box
|
||||||
))}
|
role="radiogroup"
|
||||||
</Box>
|
aria-label={primaryListAriaLabel}
|
||||||
</>
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
|
||||||
)}
|
>
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<ServiceOption
|
||||||
|
key={pkg.id}
|
||||||
|
name={pkg.name}
|
||||||
|
description={pkg.description}
|
||||||
|
price={pkg.price}
|
||||||
|
selected={selectedPackageId === pkg.id}
|
||||||
|
onClick={() => handleSelectPackage(pkg.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{packages.length === 0 && (
|
||||||
|
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No packages match your current preferences.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ─── Secondary: same-provider-more ─── */}
|
||||||
|
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 8 }} />
|
||||||
|
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
|
||||||
|
<Box
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={`Other packages from ${provider.name}`}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
mb: sameProviderOverflow ? 2 : 3,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sameProviderVisible.map((pkg) => (
|
||||||
|
<ServiceOption
|
||||||
|
key={pkg.id}
|
||||||
|
name={pkg.name}
|
||||||
|
description={pkg.description}
|
||||||
|
price={pkg.price}
|
||||||
|
selected={selectedPackageId === pkg.id}
|
||||||
|
onClick={() => handleSelectPackage(pkg.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{sameProviderOverflow && onSeeAllPackages && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={onSeeAllPackages}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See {sameProviderPackages.length - SAME_PROVIDER_INLINE_LIMIT} more packages from
|
||||||
|
this provider
|
||||||
|
<ArrowForwardIcon sx={{ fontSize: 16 }} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Secondary: nearby-verified ─── */}
|
||||||
|
{activeSecondaryList?.kind === 'nearby-verified' &&
|
||||||
|
activeSecondaryList.providers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 8 }} />
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||||
|
<VerifiedOutlinedIcon
|
||||||
|
sx={{ fontSize: 16, color: 'primary.main', mt: '3px' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||||
|
Similar packages from verified providers
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
aria-label="Similar packages from verified providers"
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeSecondaryList.providers.slice(0, NEARBY_VERIFIED_LIMIT).map((p) => (
|
||||||
|
<MiniCard
|
||||||
|
key={p.id}
|
||||||
|
title={p.name}
|
||||||
|
imageUrl={p.imageUrl}
|
||||||
|
verified
|
||||||
|
price={p.startingPrice}
|
||||||
|
location={p.location}
|
||||||
|
rating={p.rating}
|
||||||
|
onClick={onNearbyProviderClick ? () => onNearbyProviderClick(p.id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
105
src/components/pages/PackagesStep/types.ts
Normal file
105
src/components/pages/PackagesStep/types.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { PackageSection } from '../../organisms/PackageDetail';
|
||||||
|
|
||||||
|
// ─── Tier ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider tier — drives header copy, CTA label, disclaimer text, and
|
||||||
|
* whether the PackageDetail panel shows an itemised breakdown.
|
||||||
|
*
|
||||||
|
* - `verified`: Paid-listing provider. Full data, "Make Arrangement" CTA.
|
||||||
|
* - `tier3`: Unverified provider with itemised breakdown scraped from public info.
|
||||||
|
* - `tier2`: Unverified provider with total price only (no itemised breakdown).
|
||||||
|
*/
|
||||||
|
export type ProviderTier = 'verified' | 'tier3' | 'tier2';
|
||||||
|
|
||||||
|
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PackagesStepProvider {
|
||||||
|
/** Provider name */
|
||||||
|
name: string;
|
||||||
|
/** Location */
|
||||||
|
location: string;
|
||||||
|
/** Hero image — typically only supplied for verified providers */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Rating */
|
||||||
|
rating?: number;
|
||||||
|
/** Review count */
|
||||||
|
reviewCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Package data ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package data for the selection list.
|
||||||
|
*
|
||||||
|
* For `tier2` providers, callers should pass `sections: []` (and optionally
|
||||||
|
* omit `total`); the detail panel switches to "Itemised Pricing Unavailable"
|
||||||
|
* automatically based on the `providerTier` prop.
|
||||||
|
*/
|
||||||
|
export interface PackageData {
|
||||||
|
/** Unique package ID */
|
||||||
|
id: string;
|
||||||
|
/** Package display name */
|
||||||
|
name: string;
|
||||||
|
/** Package price in dollars */
|
||||||
|
price: number;
|
||||||
|
/** Short description shown on the option card */
|
||||||
|
description?: string;
|
||||||
|
/** Line-item sections for the detail panel (empty for tier2) */
|
||||||
|
sections: PackageSection[];
|
||||||
|
/** Total price shown between main sections and extras */
|
||||||
|
total?: number;
|
||||||
|
/** Extra-cost items shown after the total */
|
||||||
|
extras?: PackageSection;
|
||||||
|
/** Terms and conditions */
|
||||||
|
terms?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A verified provider surfaced on an unverified provider's PackagesStep.
|
||||||
|
*
|
||||||
|
* By definition every entry in this list is verified — the section is a
|
||||||
|
* curated "here are the real partners near you" promotion — so there is no
|
||||||
|
* `verified` flag on the data shape. Components that render this list pass
|
||||||
|
* a hard-coded `verified={true}` to their card.
|
||||||
|
*/
|
||||||
|
export interface NearbyVerifiedProvider {
|
||||||
|
/** Provider ID — routes to `/providers/:id/packages` */
|
||||||
|
id: string;
|
||||||
|
/** Provider name */
|
||||||
|
name: string;
|
||||||
|
/** Hero image URL (verified providers always have one) */
|
||||||
|
imageUrl: string;
|
||||||
|
/** Location (suburb, state) */
|
||||||
|
location: string;
|
||||||
|
/** Starting price — formatted as "From $X" on the card */
|
||||||
|
startingPrice: number;
|
||||||
|
/** Average rating */
|
||||||
|
rating?: number;
|
||||||
|
/** Number of reviews */
|
||||||
|
reviewCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Secondary list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union for the second list below the primary packages.
|
||||||
|
*
|
||||||
|
* - `same-provider-more`: Other packages from the same (verified) provider.
|
||||||
|
* Rendered as a ServiceOption list. If more than 3, the list shows the
|
||||||
|
* first 3 + a "See all N packages from [Provider]" link that navigates
|
||||||
|
* to the same PackagesStep with preference filters off.
|
||||||
|
* - `nearby-verified`: Verified providers promoted on unverified-tier pages
|
||||||
|
* under the heading "Similar packages from verified providers". Rendered
|
||||||
|
* as a 2-col MiniCard grid capped at 4. Clicking a card routes to that
|
||||||
|
* provider's PackagesStep.
|
||||||
|
*/
|
||||||
|
export type SecondaryList =
|
||||||
|
| {
|
||||||
|
kind: 'same-provider-more';
|
||||||
|
packages: PackageData[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'nearby-verified';
|
||||||
|
providers: NearbyVerifiedProvider[];
|
||||||
|
};
|
||||||
@@ -5,19 +5,25 @@ import InputAdornment from '@mui/material/InputAdornment';
|
|||||||
import Autocomplete from '@mui/material/Autocomplete';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import Slider from '@mui/material/Slider';
|
import Slider from '@mui/material/Slider';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
|
||||||
import Menu from '@mui/material/Menu';
|
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
||||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCard } from '../../molecules/ProviderCard';
|
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
import { Button } from '../../atoms/Button';
|
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
|
||||||
|
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
|
||||||
|
import { HelpBar } from '../../molecules/HelpBar';
|
||||||
|
import { SortMenu } from '../../molecules/SortMenu';
|
||||||
|
import {
|
||||||
|
ProviderMap,
|
||||||
|
type ProviderMapActiveState,
|
||||||
|
type ProviderMapHandle,
|
||||||
|
} from '../../organisms/ProviderMap';
|
||||||
import { Chip } from '../../atoms/Chip';
|
import { Chip } from '../../atoms/Chip';
|
||||||
import { Switch } from '../../atoms/Switch';
|
import { Switch } from '../../atoms/Switch';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
@@ -49,6 +55,8 @@ export interface ProviderData {
|
|||||||
distanceKm?: number;
|
distanceKm?: number;
|
||||||
/** Brief description */
|
/** Brief description */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Geographic coordinates for map display */
|
||||||
|
coords?: { lat: number; lng: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A funeral type option for the filter */
|
/** A funeral type option for the filter */
|
||||||
@@ -165,8 +173,8 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
|
|||||||
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
|
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
|
||||||
{ value: 'recommended', label: 'Recommended' },
|
{ value: 'recommended', label: 'Recommended' },
|
||||||
{ value: 'nearest', label: 'Nearest' },
|
{ value: 'nearest', label: 'Nearest' },
|
||||||
{ value: 'price_low', label: 'Price: Low to High' },
|
{ value: 'price_low', label: 'Price low to high' },
|
||||||
{ value: 'price_high', label: 'Price: High to Low' },
|
{ value: 'price_high', label: 'Price high to low' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
|
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
|
||||||
@@ -194,6 +202,98 @@ const chipWrapSx = {
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared visual tokens for the ProvidersStep control chips. Search, Filters,
|
||||||
|
* Sort by, and the List/Map toggle all reference these so their outline /
|
||||||
|
* radius / fill / shadow / height read as one coherent set. Kept on the page
|
||||||
|
* (not promoted to a design-system-wide primitive) because this is a
|
||||||
|
* page-local "control cluster" pattern — Button and Input already own their
|
||||||
|
* own radii in the theme.
|
||||||
|
*/
|
||||||
|
const CONTROL_CHROME = {
|
||||||
|
height: 32,
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderRadius: 'var(--fa-button-border-radius-default)',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */
|
||||||
|
const controlButtonSx = {
|
||||||
|
height: CONTROL_CHROME.height,
|
||||||
|
bgcolor: CONTROL_CHROME.bgcolor,
|
||||||
|
borderColor: CONTROL_CHROME.borderColor,
|
||||||
|
borderRadius: CONTROL_CHROME.borderRadius,
|
||||||
|
boxShadow: CONTROL_CHROME.boxShadow,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor },
|
||||||
|
'&:focus-visible': { outline: 'none' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** sx for the FilterPanel wrapper — targets its internal trigger Button. */
|
||||||
|
const filterTriggerSx = {
|
||||||
|
'& .MuiButton-root': controlButtonSx,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */
|
||||||
|
const controlToggleSx = {
|
||||||
|
borderRadius: CONTROL_CHROME.borderRadius,
|
||||||
|
boxShadow: CONTROL_CHROME.boxShadow,
|
||||||
|
'& .MuiToggleButton-root': {
|
||||||
|
height: CONTROL_CHROME.height,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: 'var(--fa-button-font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderColor: CONTROL_CHROME.borderColor,
|
||||||
|
bgcolor: CONTROL_CHROME.bgcolor,
|
||||||
|
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor },
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: 'var(--fa-color-brand-100)',
|
||||||
|
color: 'primary.main',
|
||||||
|
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME.
|
||||||
|
* Absolute-anchors the end adornment (commit button) to the right edge —
|
||||||
|
* MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`,
|
||||||
|
* but overriding `InputProps.endAdornment` puts the content in a
|
||||||
|
* `.MuiInputAdornment-positionEnd` (which is static by default), so the
|
||||||
|
* button slides left as chips/draft fill the input. `paddingRight` on the
|
||||||
|
* OutlinedInput reserves the lane so input content can't run under it. */
|
||||||
|
const controlInputSx = {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: CONTROL_CHROME.bgcolor,
|
||||||
|
boxShadow: CONTROL_CHROME.boxShadow,
|
||||||
|
borderRadius: CONTROL_CHROME.borderRadius,
|
||||||
|
pr: 5,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: 'none',
|
||||||
|
m: 0,
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: CONTROL_CHROME.borderColor,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-root.Mui-focused': {
|
||||||
|
boxShadow: CONTROL_CHROME.boxShadow,
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: CONTROL_CHROME.borderColor,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,8 +342,12 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
? 'Take your time exploring providers. You can always come back and choose a different one.'
|
? 'Take your time exploring providers. You can always come back and choose a different one.'
|
||||||
: 'These providers are near your location. Each has their own packages and pricing.';
|
: 'These providers are near your location. Each has their own packages and pricing.';
|
||||||
|
|
||||||
// ─── Local state ───
|
// ─── Mobile map-first plumbing ───
|
||||||
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const mapRef = React.useRef<ProviderMapHandle>(null);
|
||||||
|
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
|
||||||
|
const showMobileMapLayout = isMobile && viewMode === 'map';
|
||||||
|
|
||||||
// ─── Price input local state (commits on blur / Enter) ───
|
// ─── Price input local state (commits on blur / Enter) ───
|
||||||
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
|
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
|
||||||
@@ -294,6 +398,257 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
onFilterChange({ ...filterValues, funeralTypes: next });
|
onFilterChange({ ...filterValues, funeralTypes: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Shared JSX fragments (used by desktop + mobile-map layouts) ───────────
|
||||||
|
|
||||||
|
/** The full filter-dialog content — used by both desktop's sticky FilterPanel
|
||||||
|
* and the mobile-map floating FilterPanel. */
|
||||||
|
const filterDialogChildren = (
|
||||||
|
<>
|
||||||
|
{/* ── Service tradition ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Service tradition
|
||||||
|
</Typography>
|
||||||
|
<Autocomplete
|
||||||
|
value={filterValues.tradition}
|
||||||
|
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
||||||
|
options={traditionOptions}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} placeholder="Search traditions..." size="small" />
|
||||||
|
)}
|
||||||
|
clearOnEscape
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Funeral type ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Funeral type
|
||||||
|
</Typography>
|
||||||
|
<Box sx={chipWrapSx}>
|
||||||
|
{funeralTypeOptions.map((option) => (
|
||||||
|
<Chip
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
selected={filterValues.funeralTypes.includes(option.value)}
|
||||||
|
onClick={() => handleFuneralTypeToggle(option.value)}
|
||||||
|
variant="outlined"
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Provider features ── Switch aligned to the first text line so
|
||||||
|
wrapped labels read cleanly on narrow screens */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={filterValues.verifiedOnly}
|
||||||
|
onChange={(_, checked) => onFilterChange({ ...filterValues, verifiedOnly: checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Verified providers only"
|
||||||
|
sx={{
|
||||||
|
mx: 0,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
'& .MuiFormControlLabel-label': { pt: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={filterValues.onlineArrangements}
|
||||||
|
onChange={(_, checked) =>
|
||||||
|
onFilterChange({ ...filterValues, onlineArrangements: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Online arrangements available"
|
||||||
|
sx={{
|
||||||
|
mx: 0,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
'& .MuiFormControlLabel-label': { pt: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Price range ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Price range
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ px: 2.5, mb: 1 }}>
|
||||||
|
<Slider
|
||||||
|
value={filterValues.priceRange}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
onFilterChange({
|
||||||
|
...filterValues,
|
||||||
|
priceRange: newValue as [number, number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={minPrice}
|
||||||
|
max={maxPrice}
|
||||||
|
step={100}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={priceMinInput}
|
||||||
|
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onBlur={commitPriceRange}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
inputMode: 'numeric',
|
||||||
|
'aria-label': 'Minimum price',
|
||||||
|
style: { padding: '6px 0' },
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
–
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={priceMaxInput}
|
||||||
|
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onBlur={commitPriceRange}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
inputMode: 'numeric',
|
||||||
|
'aria-label': 'Maximum price',
|
||||||
|
style: { padding: '6px 0' },
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Mobile map-first layout ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showMobileMapLayout) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navigation}
|
||||||
|
|
||||||
|
<Box component="main" sx={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||||
|
{/* Full-bleed map */}
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, display: 'flex' }}>
|
||||||
|
<ProviderMap
|
||||||
|
ref={mapRef}
|
||||||
|
providers={providers}
|
||||||
|
onSelectProvider={onSelectProvider}
|
||||||
|
externalisePopups
|
||||||
|
onActiveChange={setMapActive}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Floating control strip — no container chrome; each control has
|
||||||
|
its own fill/border so it reads cleanly over any map tile */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search input — committed-chip pattern, chrome via controlInputSx */}
|
||||||
|
<LocationSearchInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
onCommit={onSearch}
|
||||||
|
aria-label="Search providers by town or suburb"
|
||||||
|
sx={controlInputSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Control row: Filters, Sort by, view toggle.
|
||||||
|
Each control reads as part of one chip set — shared outline,
|
||||||
|
radius, fill, and shadow via CONTROL_CHROME. */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
|
||||||
|
{filterDialogChildren}
|
||||||
|
</FilterPanel>
|
||||||
|
|
||||||
|
{/* Sort — compact trigger on the mobile floating strip */}
|
||||||
|
<SortMenu
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
|
||||||
|
options={SORT_OPTIONS}
|
||||||
|
variant="compact"
|
||||||
|
sx={controlButtonSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View toggle — right-aligned; same outline/radius/fill/shadow
|
||||||
|
as Filters + Sort, with brand fill on the selected side. */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={viewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||||
|
size="small"
|
||||||
|
aria-label="View mode"
|
||||||
|
sx={[{ ml: 'auto', flexShrink: 0 }, controlToggleSx]}
|
||||||
|
>
|
||||||
|
<ToggleButton value="list" aria-label="List view">
|
||||||
|
List
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="map" aria-label="Map view">
|
||||||
|
Map
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom drawer — slides up when a pin/cluster is active */}
|
||||||
|
<MapProviderDrawer
|
||||||
|
active={mapActive}
|
||||||
|
onClose={() => mapRef.current?.clearActive()}
|
||||||
|
onSelectProvider={onSelectProvider}
|
||||||
|
onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sticky help bar — shared HelpBar molecule so this footer stays
|
||||||
|
identical to WizardLayout's (which we bypass in this branch). */}
|
||||||
|
<HelpBar />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Desktop + mobile-list layout ──────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="list-map"
|
variant="list-map"
|
||||||
@@ -306,38 +661,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
sx={sx}
|
sx={sx}
|
||||||
secondaryPanel={
|
secondaryPanel={
|
||||||
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||||||
{/* Floating view toggle */}
|
{/* Floating view toggle — same chrome as the sticky-bar controls,
|
||||||
|
anchored to the map panel's top-left. */}
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="View mode"
|
aria-label="View mode"
|
||||||
sx={{
|
sx={[
|
||||||
position: 'absolute',
|
{ position: 'absolute', top: 12, left: 12, zIndex: 1 },
|
||||||
top: 12,
|
controlToggleSx,
|
||||||
left: 12,
|
{ '& .MuiToggleButton-root': { gap: 0.75 } },
|
||||||
zIndex: 1,
|
]}
|
||||||
bgcolor: 'background.paper',
|
|
||||||
boxShadow: 'var(--fa-shadow-md)',
|
|
||||||
borderRadius: 1,
|
|
||||||
'& .MuiToggleButton-root': {
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
gap: 0.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
textTransform: 'none',
|
|
||||||
'&.Mui-selected': {
|
|
||||||
bgcolor: 'var(--fa-color-brand-100)',
|
|
||||||
color: 'primary.main',
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ToggleButton value="list" aria-label="List view">
|
<ToggleButton value="list" aria-label="List view">
|
||||||
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
@@ -393,28 +729,15 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Location search */}
|
{/* Location search — committed location renders as a chip inside
|
||||||
<TextField
|
the input. Shared with the mobile-map floating strip via the
|
||||||
placeholder="Search a town or suburb..."
|
LocationSearchInput molecule. */}
|
||||||
aria-label="Search providers by town or suburb"
|
<LocationSearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={onSearchChange}
|
||||||
onKeyDown={(e) => {
|
onCommit={onSearch}
|
||||||
if (e.key === 'Enter' && onSearch) {
|
aria-label="Search providers by town or suburb"
|
||||||
e.preventDefault();
|
sx={[controlInputSx, { mb: 1.5 }]}
|
||||||
onSearch(searchQuery);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
sx={{ mb: 1.5 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Control bar — filters + sort */}
|
{/* Control bar — filters + sort */}
|
||||||
@@ -425,216 +748,42 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Filters */}
|
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
|
||||||
<FilterPanel activeCount={activeCount} onClear={handleClear}>
|
{filterDialogChildren}
|
||||||
{/* ── Location ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Location
|
|
||||||
</Typography>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
|
||||||
onChange={(_, newValue) => {
|
|
||||||
// Take the last entered value as the active search
|
|
||||||
const last = newValue[newValue.length - 1] ?? '';
|
|
||||||
onSearchChange(typeof last === 'string' ? last : '');
|
|
||||||
}}
|
|
||||||
options={[]}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
|
||||||
size="small"
|
|
||||||
InputProps={{
|
|
||||||
...params.InputProps,
|
|
||||||
startAdornment: (
|
|
||||||
<>
|
|
||||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ color: 'text.secondary', fontSize: 18 }}
|
|
||||||
/>
|
|
||||||
</InputAdornment>
|
|
||||||
{params.InputProps.startAdornment}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Service tradition ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Service tradition
|
|
||||||
</Typography>
|
|
||||||
<Autocomplete
|
|
||||||
value={filterValues.tradition}
|
|
||||||
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
|
||||||
options={traditionOptions}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} placeholder="Search traditions..." size="small" />
|
|
||||||
)}
|
|
||||||
clearOnEscape
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Funeral type ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Funeral type
|
|
||||||
</Typography>
|
|
||||||
<Box sx={chipWrapSx}>
|
|
||||||
{funeralTypeOptions.map((option) => (
|
|
||||||
<Chip
|
|
||||||
key={option.value}
|
|
||||||
label={option.label}
|
|
||||||
selected={filterValues.funeralTypes.includes(option.value)}
|
|
||||||
onClick={() => handleFuneralTypeToggle(option.value)}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Provider features ── */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={filterValues.verifiedOnly}
|
|
||||||
onChange={(_, checked) =>
|
|
||||||
onFilterChange({ ...filterValues, verifiedOnly: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Verified providers only"
|
|
||||||
sx={{ mx: 0 }}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={filterValues.onlineArrangements}
|
|
||||||
onChange={(_, checked) =>
|
|
||||||
onFilterChange({ ...filterValues, onlineArrangements: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Online arrangements available"
|
|
||||||
sx={{ mx: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Price range ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Price range
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ px: 2.5, mb: 1 }}>
|
|
||||||
<Slider
|
|
||||||
value={filterValues.priceRange}
|
|
||||||
onChange={(_, newValue) =>
|
|
||||||
onFilterChange({
|
|
||||||
...filterValues,
|
|
||||||
priceRange: newValue as [number, number],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
min={minPrice}
|
|
||||||
max={maxPrice}
|
|
||||||
step={100}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={priceMinInput}
|
|
||||||
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
|
|
||||||
onBlur={commitPriceRange}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
|
||||||
}}
|
|
||||||
inputProps={{
|
|
||||||
inputMode: 'numeric',
|
|
||||||
'aria-label': 'Minimum price',
|
|
||||||
style: { padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
–
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={priceMaxInput}
|
|
||||||
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
|
|
||||||
onBlur={commitPriceRange}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
|
||||||
}}
|
|
||||||
inputProps={{
|
|
||||||
inputMode: 'numeric',
|
|
||||||
'aria-label': 'Maximum price',
|
|
||||||
style: { padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
{/* Sort — compact menu button, pushed right */}
|
{/* Sort — compact "Sort by" on mobile (grouped left next to
|
||||||
<Box sx={{ ml: 'auto' }}>
|
Filters); verbose "Sort: <label>" on desktop (pushed right). */}
|
||||||
<Button
|
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
|
||||||
variant="outlined"
|
<SortMenu
|
||||||
color="secondary"
|
value={sortBy}
|
||||||
size="small"
|
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
|
||||||
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
|
options={SORT_OPTIONS}
|
||||||
onClick={(e) => setSortAnchor(e.currentTarget)}
|
variant={isMobile ? 'compact' : 'verbose'}
|
||||||
aria-haspopup="listbox"
|
sx={controlButtonSx}
|
||||||
sx={{ textTransform: 'none' }}
|
/>
|
||||||
>
|
|
||||||
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Sort'}
|
|
||||||
</Button>
|
|
||||||
<Menu
|
|
||||||
anchorEl={sortAnchor}
|
|
||||||
open={Boolean(sortAnchor)}
|
|
||||||
onClose={() => setSortAnchor(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map((opt) => (
|
|
||||||
<MenuItem
|
|
||||||
key={opt.value}
|
|
||||||
selected={opt.value === sortBy}
|
|
||||||
onClick={() => {
|
|
||||||
onSortChange?.(opt.value);
|
|
||||||
setSortAnchor(null);
|
|
||||||
}}
|
|
||||||
sx={{ fontSize: '0.813rem' }}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
|
||||||
|
Shares the same CONTROL_CHROME as Filters + Sort. */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={viewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||||
|
size="small"
|
||||||
|
aria-label="View mode"
|
||||||
|
sx={[
|
||||||
|
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
|
||||||
|
controlToggleSx,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ToggleButton value="list" aria-label="List view">
|
||||||
|
List
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="map" aria-label="Map view">
|
||||||
|
Map
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Results count — below controls */}
|
{/* Results count — below controls */}
|
||||||
@@ -644,7 +793,10 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
sx={{ mt: 3, display: 'block' }}
|
sx={{ mt: 3, display: 'block' }}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
|
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||||
|
{providers.length}
|
||||||
|
</Box>{' '}
|
||||||
|
provider{providers.length !== 1 ? 's' : ''} found
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { UnverifiedPackageT2 } from './UnverifiedPackageT2';
|
|
||||||
import type {
|
|
||||||
UnverifiedPackageT2Data,
|
|
||||||
UnverifiedPackageT2Provider,
|
|
||||||
NearbyVerifiedPackage,
|
|
||||||
} from './UnverifiedPackageT2';
|
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FALogo = () => (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src="/brandlogo/logo-full.svg"
|
|
||||||
alt="Funeral Arranger"
|
|
||||||
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src="/brandlogo/logo-short.svg"
|
|
||||||
alt="Funeral Arranger"
|
|
||||||
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const nav = (
|
|
||||||
<Navigation
|
|
||||||
logo={<FALogo />}
|
|
||||||
items={[
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockProvider: UnverifiedPackageT2Provider = {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth, NSW',
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPackages: UnverifiedPackageT2Data[] = [
|
|
||||||
{
|
|
||||||
id: 'everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 2700,
|
|
||||||
description:
|
|
||||||
'A funeral service at a chapel or church with a funeral procession, including commonly selected options.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'deluxe',
|
|
||||||
name: 'Deluxe Funeral Package',
|
|
||||||
price: 4900,
|
|
||||||
description: 'A comprehensive package with premium inclusions and expanded service options.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'catholic',
|
|
||||||
name: 'Catholic Service',
|
|
||||||
price: 3200,
|
|
||||||
description:
|
|
||||||
'Tailored for Catholic funeral traditions including a Requiem Mass and graveside prayers.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
|
|
||||||
{
|
|
||||||
id: 'rankins-standard',
|
|
||||||
packageName: 'Standard Cremation Package',
|
|
||||||
price: 2450,
|
|
||||||
providerName: 'Rankins Funerals',
|
|
||||||
location: 'Warrawong, NSW',
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 23,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'easy-essential',
|
|
||||||
packageName: 'Essential Funeral Service',
|
|
||||||
price: 1950,
|
|
||||||
providerName: 'Easy Funerals',
|
|
||||||
location: 'Sydney, NSW',
|
|
||||||
rating: 4.5,
|
|
||||||
reviewCount: 42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'killick-classic',
|
|
||||||
packageName: 'Classic Farewell Package',
|
|
||||||
price: 3100,
|
|
||||||
providerName: 'Killick Family Funerals',
|
|
||||||
location: 'Shellharbour, NSW',
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 15,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof UnverifiedPackageT2> = {
|
|
||||||
title: 'Pages/UnverifiedPackageT2',
|
|
||||||
component: UnverifiedPackageT2,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof UnverifiedPackageT2>;
|
|
||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Select a package to see the "Itemised Pricing Unavailable" detail panel */
|
|
||||||
export const Default: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT2
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={mockPackages}
|
|
||||||
nearbyPackages={nearbyVerifiedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Make an enquiry')}
|
|
||||||
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── With selection ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Package selected — detail panel shows price + unavailable notice */
|
|
||||||
export const WithSelection: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT2
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={mockPackages}
|
|
||||||
nearbyPackages={nearbyVerifiedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Make an enquiry')}
|
|
||||||
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── No nearby packages ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Only this provider's packages — no nearby verified section */
|
|
||||||
export const NoNearbyPackages: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT2
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={mockPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Make an enquiry')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Validation error ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Error shown when no package selected */
|
|
||||||
export const WithError: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT2
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={mockPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => {}}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
error="Please choose a package to continue."
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
|
||||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
|
||||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Provider summary for the compact card */
|
|
||||||
export interface UnverifiedPackageT2Provider {
|
|
||||||
/** Provider name */
|
|
||||||
name: string;
|
|
||||||
/** Location */
|
|
||||||
location: string;
|
|
||||||
/** Image URL */
|
|
||||||
imageUrl?: string;
|
|
||||||
/** Rating */
|
|
||||||
rating?: number;
|
|
||||||
/** Review count */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Package data — price only, no itemised breakdown */
|
|
||||||
export interface UnverifiedPackageT2Data {
|
|
||||||
/** Unique package ID */
|
|
||||||
id: string;
|
|
||||||
/** Package display name */
|
|
||||||
name: string;
|
|
||||||
/** Package price in dollars */
|
|
||||||
price: number;
|
|
||||||
/** Short description */
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A similar package from a nearby verified provider */
|
|
||||||
export interface NearbyVerifiedPackage {
|
|
||||||
/** Unique ID */
|
|
||||||
id: string;
|
|
||||||
/** Package name */
|
|
||||||
packageName: string;
|
|
||||||
/** Package price in dollars */
|
|
||||||
price: number;
|
|
||||||
/** Provider name */
|
|
||||||
providerName: string;
|
|
||||||
/** Provider location */
|
|
||||||
location: string;
|
|
||||||
/** Provider rating */
|
|
||||||
rating?: number;
|
|
||||||
/** Number of reviews */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for the UnverifiedPackageT2 page component */
|
|
||||||
export interface UnverifiedPackageT2Props {
|
|
||||||
/** Provider summary shown at top of the list panel (no image — unverified provider) */
|
|
||||||
provider: UnverifiedPackageT2Provider;
|
|
||||||
/** Packages with price only (no itemised breakdown) */
|
|
||||||
packages: UnverifiedPackageT2Data[];
|
|
||||||
/** Similar packages from nearby verified providers */
|
|
||||||
nearbyPackages?: NearbyVerifiedPackage[];
|
|
||||||
/** Currently selected package ID */
|
|
||||||
selectedPackageId: string | null;
|
|
||||||
/** Callback when a package is selected */
|
|
||||||
onSelectPackage: (id: string) => void;
|
|
||||||
/** Callback when "Make an enquiry" is clicked */
|
|
||||||
onArrange: () => void;
|
|
||||||
/** Callback when a nearby verified package is clicked */
|
|
||||||
onNearbyPackageClick?: (id: string) => void;
|
|
||||||
/** Callback when the provider card is clicked */
|
|
||||||
onProviderClick?: () => void;
|
|
||||||
/** Callback for the Back button */
|
|
||||||
onBack: () => void;
|
|
||||||
/** Validation error */
|
|
||||||
error?: string;
|
|
||||||
/** Whether the enquiry action is loading */
|
|
||||||
loading?: boolean;
|
|
||||||
/** Navigation bar */
|
|
||||||
navigation?: React.ReactNode;
|
|
||||||
/** Whether this is a pre-planning flow */
|
|
||||||
isPrePlanning?: boolean;
|
|
||||||
/** MUI sx prop */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UnverifiedPackageT2 — Package selection page for Tier 2 unverified providers.
|
|
||||||
*
|
|
||||||
* Similar to T3 but the provider has only shared overall package prices,
|
|
||||||
* not itemised breakdowns. The detail panel shows an "Itemized Pricing
|
|
||||||
* Unavailable" notice instead of line items.
|
|
||||||
*
|
|
||||||
* Two sections:
|
|
||||||
* - **This provider's packages**: price-only, no breakdown available
|
|
||||||
* - **Similar packages from verified providers nearby**: promoted alternatives
|
|
||||||
*
|
|
||||||
* Pure presentation component — props in, callbacks out.
|
|
||||||
*/
|
|
||||||
export const UnverifiedPackageT2: React.FC<UnverifiedPackageT2Props> = ({
|
|
||||||
provider,
|
|
||||||
packages,
|
|
||||||
nearbyPackages = [],
|
|
||||||
selectedPackageId,
|
|
||||||
onSelectPackage,
|
|
||||||
onArrange,
|
|
||||||
onNearbyPackageClick,
|
|
||||||
onProviderClick,
|
|
||||||
onBack,
|
|
||||||
error,
|
|
||||||
loading = false,
|
|
||||||
navigation,
|
|
||||||
isPrePlanning = false,
|
|
||||||
sx,
|
|
||||||
}) => {
|
|
||||||
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
|
||||||
const hasNearbyPackages = nearbyPackages.length > 0;
|
|
||||||
|
|
||||||
const subheading = isPrePlanning
|
|
||||||
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
|
||||||
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WizardLayout
|
|
||||||
variant="list-detail"
|
|
||||||
navigation={navigation}
|
|
||||||
showBackLink
|
|
||||||
backLabel="Back"
|
|
||||||
onBack={onBack}
|
|
||||||
sx={sx}
|
|
||||||
secondaryPanel={
|
|
||||||
selectedPackage ? (
|
|
||||||
<PackageDetail
|
|
||||||
name={selectedPackage.name}
|
|
||||||
price={selectedPackage.price}
|
|
||||||
sections={[]}
|
|
||||||
onArrange={onArrange}
|
|
||||||
arrangeDisabled={loading}
|
|
||||||
arrangeLabel="Make an enquiry"
|
|
||||||
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
|
|
||||||
itemizedUnavailable
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: 300,
|
|
||||||
bgcolor: 'var(--fa-color-brand-50)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
Select a package to see more details.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Provider compact card — no image for unverified */}
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<ProviderCardCompact
|
|
||||||
name={provider.name}
|
|
||||||
location={provider.location}
|
|
||||||
rating={provider.rating}
|
|
||||||
reviewCount={provider.reviewCount}
|
|
||||||
onClick={onProviderClick}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Heading */}
|
|
||||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
|
||||||
Explore available packages
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
{subheading}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── Packages ─── */}
|
|
||||||
<Box
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Funeral packages"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
|
||||||
>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<ServiceOption
|
|
||||||
key={pkg.id}
|
|
||||||
name={pkg.name}
|
|
||||||
description={pkg.description}
|
|
||||||
price={pkg.price}
|
|
||||||
selected={selectedPackageId === pkg.id}
|
|
||||||
onClick={() => onSelectPackage(pkg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{packages.length === 0 && (
|
|
||||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No packages match your current preferences.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ─── Similar packages from nearby verified providers ─── */}
|
|
||||||
{hasNearbyPackages && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ mb: 2.5 }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
|
||||||
Similar packages from verified providers nearby
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
aria-label="Similar packages from nearby verified providers"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
|
||||||
>
|
|
||||||
{nearbyPackages.map((pkg) => (
|
|
||||||
<Card
|
|
||||||
key={pkg.id}
|
|
||||||
variant="outlined"
|
|
||||||
interactive={!!onNearbyPackageClick}
|
|
||||||
padding="none"
|
|
||||||
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
|
|
||||||
sx={{ p: 'var(--fa-card-padding-compact)' }}
|
|
||||||
>
|
|
||||||
{/* Package name + price */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" component="span">
|
|
||||||
{pkg.packageName}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="labelLg"
|
|
||||||
component="span"
|
|
||||||
color="primary"
|
|
||||||
sx={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
${pkg.price.toLocaleString('en-AU')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Provider info */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{pkg.providerName}
|
|
||||||
</Typography>
|
|
||||||
{pkg.rating != null && (
|
|
||||||
<>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
·
|
|
||||||
</Typography>
|
|
||||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.rating}
|
|
||||||
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
·
|
|
||||||
</Typography>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'text.secondary' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</WizardLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
UnverifiedPackageT2.displayName = 'UnverifiedPackageT2';
|
|
||||||
export default UnverifiedPackageT2;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './UnverifiedPackageT2';
|
|
||||||
export * from './UnverifiedPackageT2';
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { UnverifiedPackageT3 } from './UnverifiedPackageT3';
|
|
||||||
import type {
|
|
||||||
UnverifiedPackageT3Data,
|
|
||||||
UnverifiedPackageT3Provider,
|
|
||||||
NearbyVerifiedPackage,
|
|
||||||
} from './UnverifiedPackageT3';
|
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FALogo = () => (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src="/brandlogo/logo-full.svg"
|
|
||||||
alt="Funeral Arranger"
|
|
||||||
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src="/brandlogo/logo-short.svg"
|
|
||||||
alt="Funeral Arranger"
|
|
||||||
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const nav = (
|
|
||||||
<Navigation
|
|
||||||
logo={<FALogo />}
|
|
||||||
items={[
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockProvider: UnverifiedPackageT3Provider = {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth, NSW',
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchedPackages: UnverifiedPackageT3Data[] = [
|
|
||||||
{
|
|
||||||
id: 'everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 2700,
|
|
||||||
description:
|
|
||||||
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{ name: 'Accommodation', price: 500 },
|
|
||||||
{ name: 'Death registration certificate', price: 150 },
|
|
||||||
{ name: 'Doctor fee for Cremation', price: 150 },
|
|
||||||
{ name: 'NSW Government Levy - Cremation', price: 83 },
|
|
||||||
{ name: 'Professional Mortuary Care', price: 1200 },
|
|
||||||
{ name: 'Professional Service Fee', price: 1120 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Complimentary Items',
|
|
||||||
items: [
|
|
||||||
{ name: 'Dressing Fee', price: 0 },
|
|
||||||
{ name: 'Viewing Fee', price: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 2700,
|
|
||||||
extras: {
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Flowers', price: 150, isAllowance: true },
|
|
||||||
{ name: 'Allowance for Master of Ceremonies', price: 500, isAllowance: true },
|
|
||||||
{ name: 'After Business Hours Service Surcharge', price: 150 },
|
|
||||||
{ name: 'After Hours Prayers', price: 1920 },
|
|
||||||
{ name: 'Coffin Bearing by Funeral Directors', price: 1500 },
|
|
||||||
{ name: 'Digital Recording', price: 500 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
terms:
|
|
||||||
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
|
|
||||||
{
|
|
||||||
id: 'rankins-standard',
|
|
||||||
packageName: 'Standard Cremation Package',
|
|
||||||
price: 2450,
|
|
||||||
providerName: 'Rankins Funerals',
|
|
||||||
location: 'Warrawong, NSW',
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 23,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'easy-essential',
|
|
||||||
packageName: 'Essential Funeral Service',
|
|
||||||
price: 1950,
|
|
||||||
providerName: 'Easy Funerals',
|
|
||||||
location: 'Sydney, NSW',
|
|
||||||
rating: 4.5,
|
|
||||||
reviewCount: 42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'killick-classic',
|
|
||||||
packageName: 'Classic Farewell Package',
|
|
||||||
price: 3100,
|
|
||||||
providerName: 'Killick Family Funerals',
|
|
||||||
location: 'Shellharbour, NSW',
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 15,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof UnverifiedPackageT3> = {
|
|
||||||
title: 'Pages/UnverifiedPackageT3',
|
|
||||||
component: UnverifiedPackageT3,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof UnverifiedPackageT3>;
|
|
||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
|
||||||
export const Default: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT3
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
nearbyPackages={nearbyVerifiedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── With selection ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Package already selected — detail panel visible */
|
|
||||||
export const WithSelection: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT3
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
nearbyPackages={nearbyVerifiedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── No other packages (all match) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/** No nearby verified packages — only this provider's packages */
|
|
||||||
export const NoNearbyPackages: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT3
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Pre-planning flow — softer copy */
|
|
||||||
export const PrePlanning: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT3
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
nearbyPackages={nearbyVerifiedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
isPrePlanning
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Validation error ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Error shown when no package selected */
|
|
||||||
export const WithError: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnverifiedPackageT3
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={matchedPackages}
|
|
||||||
selectedPackageId={selectedId}
|
|
||||||
onSelectPackage={setSelectedId}
|
|
||||||
onArrange={() => {}}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
error="Please choose a package to continue."
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
|
||||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
|
||||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
|
||||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Provider summary for the compact card */
|
|
||||||
export interface UnverifiedPackageT3Provider {
|
|
||||||
/** Provider name */
|
|
||||||
name: string;
|
|
||||||
/** Location */
|
|
||||||
location: string;
|
|
||||||
/** Image URL */
|
|
||||||
imageUrl?: string;
|
|
||||||
/** Rating */
|
|
||||||
rating?: number;
|
|
||||||
/** Review count */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Package data for the selection list */
|
|
||||||
export interface UnverifiedPackageT3Data {
|
|
||||||
/** Unique package ID */
|
|
||||||
id: string;
|
|
||||||
/** Package display name */
|
|
||||||
name: string;
|
|
||||||
/** Package price in dollars */
|
|
||||||
price: number;
|
|
||||||
/** Short description */
|
|
||||||
description?: string;
|
|
||||||
/** Line item sections for the detail panel */
|
|
||||||
sections: PackageSection[];
|
|
||||||
/** Total price (may differ from base price with extras) */
|
|
||||||
total?: number;
|
|
||||||
/** Extra items section (after total) */
|
|
||||||
extras?: PackageSection;
|
|
||||||
/** Terms and conditions */
|
|
||||||
terms?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A similar package from a nearby verified provider */
|
|
||||||
export interface NearbyVerifiedPackage {
|
|
||||||
/** Unique ID */
|
|
||||||
id: string;
|
|
||||||
/** Package name */
|
|
||||||
packageName: string;
|
|
||||||
/** Package price in dollars */
|
|
||||||
price: number;
|
|
||||||
/** Provider name */
|
|
||||||
providerName: string;
|
|
||||||
/** Provider location */
|
|
||||||
location: string;
|
|
||||||
/** Provider rating */
|
|
||||||
rating?: number;
|
|
||||||
/** Number of reviews */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for the UnverifiedPackageT3 page component */
|
|
||||||
export interface UnverifiedPackageT3Props {
|
|
||||||
/** Provider summary shown at top of the list panel (no image — unverified provider) */
|
|
||||||
provider: UnverifiedPackageT3Provider;
|
|
||||||
/** Packages matching the user's filters from the previous step */
|
|
||||||
packages: UnverifiedPackageT3Data[];
|
|
||||||
/** Similar packages from nearby verified providers */
|
|
||||||
nearbyPackages?: NearbyVerifiedPackage[];
|
|
||||||
/** Currently selected package ID */
|
|
||||||
selectedPackageId: string | null;
|
|
||||||
/** Callback when a package is selected */
|
|
||||||
onSelectPackage: (id: string) => void;
|
|
||||||
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
|
|
||||||
onArrange: () => void;
|
|
||||||
/** Callback when a nearby verified package is clicked */
|
|
||||||
onNearbyPackageClick?: (id: string) => void;
|
|
||||||
/** Callback when the provider card is clicked (opens provider profile popup) */
|
|
||||||
onProviderClick?: () => void;
|
|
||||||
/** Callback for the Back button */
|
|
||||||
onBack: () => void;
|
|
||||||
/** Validation error */
|
|
||||||
error?: string;
|
|
||||||
/** Whether the arrange action is loading */
|
|
||||||
loading?: boolean;
|
|
||||||
/** Navigation bar */
|
|
||||||
navigation?: React.ReactNode;
|
|
||||||
/** Whether this is a pre-planning flow */
|
|
||||||
isPrePlanning?: boolean;
|
|
||||||
/** MUI sx prop */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UnverifiedPackageT3 — Package selection page for unverified (Tier 3) providers.
|
|
||||||
*
|
|
||||||
* List + Detail split layout. Left panel shows the selected provider
|
|
||||||
* (compact) and selectable package cards. Right panel shows the full
|
|
||||||
* detail breakdown of the selected package with "Make Arrangement" CTA.
|
|
||||||
*
|
|
||||||
* Two sections:
|
|
||||||
* - **This provider's packages**: estimated pricing from publicly available info
|
|
||||||
* - **Similar packages from verified providers nearby**: promoted alternatives
|
|
||||||
* with verified pricing, ratings, and location
|
|
||||||
*
|
|
||||||
* Selecting a package reveals its detail. Clicking "Make an enquiry"
|
|
||||||
* on the detail panel initiates contact with the unverified provider.
|
|
||||||
*
|
|
||||||
* Pure presentation component — props in, callbacks out.
|
|
||||||
*/
|
|
||||||
export const UnverifiedPackageT3: React.FC<UnverifiedPackageT3Props> = ({
|
|
||||||
provider,
|
|
||||||
packages,
|
|
||||||
nearbyPackages = [],
|
|
||||||
selectedPackageId,
|
|
||||||
onSelectPackage,
|
|
||||||
onArrange,
|
|
||||||
onNearbyPackageClick,
|
|
||||||
onProviderClick,
|
|
||||||
onBack,
|
|
||||||
error,
|
|
||||||
loading = false,
|
|
||||||
navigation,
|
|
||||||
isPrePlanning = false,
|
|
||||||
sx,
|
|
||||||
}) => {
|
|
||||||
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
|
||||||
const hasNearbyPackages = nearbyPackages.length > 0;
|
|
||||||
|
|
||||||
const subheading = isPrePlanning
|
|
||||||
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
|
||||||
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WizardLayout
|
|
||||||
variant="list-detail"
|
|
||||||
navigation={navigation}
|
|
||||||
showBackLink
|
|
||||||
backLabel="Back"
|
|
||||||
onBack={onBack}
|
|
||||||
sx={sx}
|
|
||||||
secondaryPanel={
|
|
||||||
selectedPackage ? (
|
|
||||||
<PackageDetail
|
|
||||||
name={selectedPackage.name}
|
|
||||||
price={selectedPackage.price}
|
|
||||||
sections={selectedPackage.sections}
|
|
||||||
total={selectedPackage.total}
|
|
||||||
extras={selectedPackage.extras}
|
|
||||||
terms={selectedPackage.terms}
|
|
||||||
onArrange={onArrange}
|
|
||||||
arrangeDisabled={loading}
|
|
||||||
arrangeLabel="Make an enquiry"
|
|
||||||
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: 300,
|
|
||||||
bgcolor: 'var(--fa-color-brand-50)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
Select a package to see what's included.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Provider compact card — clickable to open provider profile */}
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<ProviderCardCompact
|
|
||||||
name={provider.name}
|
|
||||||
location={provider.location}
|
|
||||||
rating={provider.rating}
|
|
||||||
reviewCount={provider.reviewCount}
|
|
||||||
onClick={onProviderClick}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Heading */}
|
|
||||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
|
||||||
Explore available packages
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
{subheading}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── Packages ─── */}
|
|
||||||
<Box
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Funeral packages"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
|
||||||
>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<ServiceOption
|
|
||||||
key={pkg.id}
|
|
||||||
name={pkg.name}
|
|
||||||
description={pkg.description}
|
|
||||||
price={pkg.price}
|
|
||||||
selected={selectedPackageId === pkg.id}
|
|
||||||
onClick={() => onSelectPackage(pkg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{packages.length === 0 && (
|
|
||||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No packages match your current preferences.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ─── Similar packages from nearby verified providers ─── */}
|
|
||||||
{hasNearbyPackages && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ mb: 2.5 }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
|
||||||
Similar packages from verified providers nearby
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
aria-label="Similar packages from nearby verified providers"
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
|
||||||
>
|
|
||||||
{nearbyPackages.map((pkg) => (
|
|
||||||
<Card
|
|
||||||
key={pkg.id}
|
|
||||||
variant="outlined"
|
|
||||||
interactive={!!onNearbyPackageClick}
|
|
||||||
padding="none"
|
|
||||||
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
|
|
||||||
sx={{ p: 'var(--fa-card-padding-compact)' }}
|
|
||||||
>
|
|
||||||
{/* Package name + price */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" component="span">
|
|
||||||
{pkg.packageName}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="labelLg"
|
|
||||||
component="span"
|
|
||||||
color="primary"
|
|
||||||
sx={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
${pkg.price.toLocaleString('en-AU')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Provider info */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{pkg.providerName}
|
|
||||||
</Typography>
|
|
||||||
{pkg.rating != null && (
|
|
||||||
<>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
·
|
|
||||||
</Typography>
|
|
||||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.rating}
|
|
||||||
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
·
|
|
||||||
</Typography>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'text.secondary' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</WizardLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
UnverifiedPackageT3.displayName = 'UnverifiedPackageT3';
|
|
||||||
export default UnverifiedPackageT3;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './UnverifiedPackageT3';
|
|
||||||
export * from './UnverifiedPackageT3';
|
|
||||||
@@ -2,10 +2,9 @@ import React from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import PhoneIcon from '@mui/icons-material/Phone';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { Link } from '../../atoms/Link';
|
import { Link } from '../../atoms/Link';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { HelpBar } from '../../molecules/HelpBar';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -16,7 +15,8 @@ export type WizardLayoutVariant =
|
|||||||
| 'list-map'
|
| 'list-map'
|
||||||
| 'list-detail'
|
| 'list-detail'
|
||||||
| 'grid-sidebar'
|
| 'grid-sidebar'
|
||||||
| 'detail-toggles';
|
| 'detail-toggles'
|
||||||
|
| 'bleed';
|
||||||
|
|
||||||
/** Props for the WizardLayout template */
|
/** Props for the WizardLayout template */
|
||||||
export interface WizardLayoutProps {
|
export interface WizardLayoutProps {
|
||||||
@@ -50,33 +50,6 @@ export interface WizardLayoutProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Help bar ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
|
|
||||||
<Box
|
|
||||||
component="footer"
|
|
||||||
sx={{
|
|
||||||
position: 'sticky',
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
py: 1.5,
|
|
||||||
px: { xs: 2, md: 4 },
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary" component="span">
|
|
||||||
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
|
|
||||||
Need help? Call us on{' '}
|
|
||||||
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
|
|
||||||
{phone}
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Back link ───────────────────────────────────────────────────────────────
|
// ─── Back link ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
|
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
|
||||||
@@ -362,6 +335,30 @@ const DetailTogglesLayout: React.FC<{
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Bleed: full-width scroll host. Main becomes the single scroll container
|
||||||
|
* (both axes). No inner Container — children are full-bleed. Back link is
|
||||||
|
* passed into children so it scrolls with the page content. Used by pages
|
||||||
|
* that own their own width + alignment logic (e.g. ComparisonPage). */
|
||||||
|
const BleedLayout: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
backLink?: React.ReactNode;
|
||||||
|
}> = ({ children, backLink }) => (
|
||||||
|
<Box
|
||||||
|
id="wizard-scroll"
|
||||||
|
data-wizard-scroll
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backLink}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Variant map ─────────────────────────────────────────────────────────────
|
// ─── Variant map ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LAYOUT_MAP: Record<
|
const LAYOUT_MAP: Record<
|
||||||
@@ -378,6 +375,7 @@ const LAYOUT_MAP: Record<
|
|||||||
'list-detail': ListDetailLayout,
|
'list-detail': ListDetailLayout,
|
||||||
'grid-sidebar': GridSidebarLayout,
|
'grid-sidebar': GridSidebarLayout,
|
||||||
'detail-toggles': DetailTogglesLayout,
|
'detail-toggles': DetailTogglesLayout,
|
||||||
|
bleed: BleedLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
|
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
|
||||||
@@ -387,12 +385,15 @@ const LAYOUT_MAP: Record<
|
|||||||
/**
|
/**
|
||||||
* Page-level layout template for the FA arrangement wizard.
|
* Page-level layout template for the FA arrangement wizard.
|
||||||
*
|
*
|
||||||
* Provides 5 layout variants matching the wizard page templates:
|
* Provides 6 layout variants matching the wizard page templates:
|
||||||
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
|
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
|
||||||
|
* - **wide-form**: Wider single column for card grids (coffins, etc.)
|
||||||
* - **list-map**: Split view with scrollable card list and map panel (providers)
|
* - **list-map**: Split view with scrollable card list and map panel (providers)
|
||||||
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
|
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
|
||||||
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
|
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
|
||||||
* - **detail-toggles**: Hero image + info column (venue, coffin details)
|
* - **detail-toggles**: Hero image + info column (venue, coffin details)
|
||||||
|
* - **bleed**: Viewport-locked, full-width scroll host with no inner container —
|
||||||
|
* the page owns its own alignment (comparison page)
|
||||||
*
|
*
|
||||||
* All variants share: navigation slot, optional back link, sticky help bar,
|
* All variants share: navigation slot, optional back link, sticky help bar,
|
||||||
* and optional progress stepper + running total bar (shown when props provided).
|
* and optional progress stepper + running total bar (shown when props provided).
|
||||||
@@ -426,8 +427,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
bgcolor: 'background.default',
|
bgcolor: 'background.default',
|
||||||
// list-map + detail-toggles: lock to viewport so panels scroll independently
|
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
|
||||||
...((variant === 'list-map' || variant === 'detail-toggles') && {
|
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}),
|
}),
|
||||||
@@ -445,15 +446,19 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
|||||||
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
|
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
|
||||||
<StepperBar stepper={progressStepper} total={runningTotal} />
|
<StepperBar stepper={progressStepper} total={runningTotal} />
|
||||||
|
|
||||||
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
|
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
|
||||||
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
|
above content for other variants */}
|
||||||
<Container
|
{showBackLink &&
|
||||||
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
|
variant !== 'list-map' &&
|
||||||
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
|
variant !== 'detail-toggles' &&
|
||||||
>
|
variant !== 'bleed' && (
|
||||||
<BackLink label={backLabel} onClick={onBack} />
|
<Container
|
||||||
</Container>
|
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
|
||||||
)}
|
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
|
||||||
|
>
|
||||||
|
<BackLink label={backLabel} onClick={onBack} />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box
|
<Box
|
||||||
@@ -463,7 +468,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
|||||||
<LayoutComponent
|
<LayoutComponent
|
||||||
secondaryPanel={secondaryPanel}
|
secondaryPanel={secondaryPanel}
|
||||||
backLink={
|
backLink={
|
||||||
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
|
showBackLink &&
|
||||||
|
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
|
||||||
<Box sx={{ pt: 1.5 }}>
|
<Box sx={{ pt: 1.5 }}>
|
||||||
<BackLink label={backLabel} onClick={onBack} />
|
<BackLink label={backLabel} onClick={onBack} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
22
src/demo/apps/arrangement/App.tsx
Normal file
22
src/demo/apps/arrangement/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useBasketUrlSync } from '../../shared/state/useBasketUrlSync';
|
||||||
|
import { ProvidersRoute } from './routes/Providers';
|
||||||
|
import { PackagesRoute } from './routes/Packages';
|
||||||
|
import { ComparisonRoute } from './routes/Comparison';
|
||||||
|
import { AppCompareBar } from './AppCompareBar';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
useBasketUrlSync();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ProvidersRoute />} />
|
||||||
|
<Route path="/providers/:providerId/packages" element={<PackagesRoute />} />
|
||||||
|
<Route path="/comparison" element={<ComparisonRoute />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<AppCompareBar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { CompareBar, type CompareBarPackage } from '../../../components/molecules/CompareBar';
|
||||||
|
import { useComparisonBasket } from '../../shared/state/useComparisonBasket';
|
||||||
|
import { resolveComparisonPackage, parseBasketKey } from '../../shared/fixtures/packages';
|
||||||
|
|
||||||
|
const ERROR_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-level CompareBar — hovers above every route except `/comparison`
|
||||||
|
* itself. Reads the basket store, resolves keys to display labels, and
|
||||||
|
* navigates to the comparison page when the user activates it.
|
||||||
|
*
|
||||||
|
* Surfaces transient error feedback (already-added / max-reached) by
|
||||||
|
* forwarding `lastError` to CompareBar and auto-clearing after a moment.
|
||||||
|
*/
|
||||||
|
export function AppCompareBar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const packageKeys = useComparisonBasket((s) => s.packageKeys);
|
||||||
|
const lastError = useComparisonBasket((s) => s.lastError);
|
||||||
|
const clearError = useComparisonBasket((s) => s.clearError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastError) return;
|
||||||
|
const t = setTimeout(clearError, ERROR_TIMEOUT_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [lastError, clearError]);
|
||||||
|
|
||||||
|
if (location.pathname.startsWith('/comparison')) return null;
|
||||||
|
|
||||||
|
const packages: CompareBarPackage[] = packageKeys
|
||||||
|
.map((key) => {
|
||||||
|
const pkg = resolveComparisonPackage(key);
|
||||||
|
const parsed = parseBasketKey(key);
|
||||||
|
if (!pkg || !parsed) return null;
|
||||||
|
return {
|
||||||
|
id: key,
|
||||||
|
name: pkg.name,
|
||||||
|
providerName: pkg.provider.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p): p is CompareBarPackage => p !== null);
|
||||||
|
|
||||||
|
// CompareBar slides in only when packages.length > 0. To surface "already
|
||||||
|
// added" / "max reached" errors when the bar isn't yet visible (no items),
|
||||||
|
// we'd need a separate toast. For now: errors only appear once the bar is
|
||||||
|
// visible — fine for the common dupe case (basket has ≥1).
|
||||||
|
return (
|
||||||
|
<CompareBar
|
||||||
|
packages={packages}
|
||||||
|
onCompare={() => navigate('/comparison')}
|
||||||
|
error={lastError ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/demo/apps/arrangement/DemoNav.tsx
Normal file
31
src/demo/apps/arrangement/DemoNav.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { Navigation } from '../../../components/organisms/Navigation';
|
||||||
|
import { assetUrl } from '../../shared/assets';
|
||||||
|
|
||||||
|
const FALogo = () => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={assetUrl('/brandlogo/logo-full.svg')}
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={assetUrl('/brandlogo/logo-short.svg')}
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const demoNav = (
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogo />}
|
||||||
|
items={[
|
||||||
|
{ label: 'FAQ', href: '#' },
|
||||||
|
{ label: 'Contact Us', href: '#' },
|
||||||
|
{ label: 'Log in', href: '#' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
18
src/demo/apps/arrangement/index.html
Normal file
18
src/demo/apps/arrangement/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Arrangement Demo — Funeral Arranger</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
src/demo/apps/arrangement/main.tsx
Normal file
23
src/demo/apps/arrangement/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
import '../../../theme/generated/tokens.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
// Vite's `base` is `/arrangement/` in production. In dev the root is this app
|
||||||
|
// folder so base is `/`. import.meta.env.BASE_URL gives us the right value.
|
||||||
|
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter basename={basename}>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
73
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
73
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ComparisonPage } from '../../../../components/pages/ComparisonPage';
|
||||||
|
import { Typography } from '../../../../components/atoms/Typography';
|
||||||
|
import { Button } from '../../../../components/atoms/Button';
|
||||||
|
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
|
||||||
|
import { resolveComparisonPackage, DEMO_RECOMMENDED_KEY } from '../../../shared/fixtures/packages';
|
||||||
|
import { demoNav } from '../DemoNav';
|
||||||
|
|
||||||
|
export function ComparisonRoute() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const packageKeys = useComparisonBasket((s) => s.packageKeys);
|
||||||
|
const remove = useComparisonBasket((s) => s.remove);
|
||||||
|
|
||||||
|
// The system-recommended package is shown as an extra column on top of
|
||||||
|
// the user's basket. Dedupe against the basket so it never renders twice.
|
||||||
|
const recommendedPackage = resolveComparisonPackage(DEMO_RECOMMENDED_KEY) ?? undefined;
|
||||||
|
|
||||||
|
const packages = packageKeys
|
||||||
|
.filter((key) => key !== DEMO_RECOMMENDED_KEY)
|
||||||
|
.map((key) => {
|
||||||
|
const resolved = resolveComparisonPackage(key);
|
||||||
|
return resolved ? { key, pkg: resolved } : null;
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(x): x is { key: string; pkg: NonNullable<ReturnType<typeof resolveComparisonPackage>> } =>
|
||||||
|
x !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty state only when there's genuinely nothing to show — normally the
|
||||||
|
// recommended package will always resolve, so this branch is defensive.
|
||||||
|
if (packages.length === 0 && !recommendedPackage) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{demoNav}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Nothing to compare yet</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Pick a provider, choose a package, then tap Compare.
|
||||||
|
</Typography>
|
||||||
|
<Button onClick={() => navigate('/')}>Browse providers</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComparisonPage
|
||||||
|
packages={packages.map((p) => p.pkg)}
|
||||||
|
recommendedPackage={recommendedPackage}
|
||||||
|
onArrange={(id) => alert(`Arrange "${id}" — would route to next wizard step.`)}
|
||||||
|
onRemove={(id) => {
|
||||||
|
// ComparisonPackage.id is the bare package id; we need the basket's
|
||||||
|
// compound key. Find it back via the parallel array.
|
||||||
|
const entry = packages.find((p) => p.pkg.id === id);
|
||||||
|
if (entry) remove(entry.key);
|
||||||
|
}}
|
||||||
|
onBack={() => navigate(-1)}
|
||||||
|
navigation={demoNav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
76
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { PackagesStep } from '../../../../components/pages/PackagesStep';
|
||||||
|
import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/providers';
|
||||||
|
import {
|
||||||
|
packagesByProvider,
|
||||||
|
makeBasketKey,
|
||||||
|
nearbyVerifiedProviders,
|
||||||
|
} from '../../../shared/fixtures/packages';
|
||||||
|
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
|
||||||
|
import { demoNav } from '../DemoNav';
|
||||||
|
|
||||||
|
export function PackagesRoute() {
|
||||||
|
const { providerId = '' } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const provider = providersById[providerId];
|
||||||
|
const bundle = packagesByProvider[providerId];
|
||||||
|
const basket = useComparisonBasket();
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(bundle?.matching[0]?.id ?? null);
|
||||||
|
|
||||||
|
if (!provider || !bundle) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
// Compare CTA on the PackageDetail panel toggles the selection in the
|
||||||
|
// basket — adds when absent, removes when present. The button's visible
|
||||||
|
// state (Compare / Added + ✓) reflects `isSelectedInCart` below. The
|
||||||
|
// floating CompareBar (mounted in App.tsx) handles navigation once the
|
||||||
|
// user has 2+ packages selected.
|
||||||
|
const handleCompare = () => {
|
||||||
|
if (selectedId) basket.toggle(makeBasketKey(provider.id, selectedId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the selected package is already in the basket, PackageDetail swaps
|
||||||
|
// the Compare button into its "In comparison" selected state.
|
||||||
|
const isSelectedInCart = selectedId ? basket.has(makeBasketKey(provider.id, selectedId)) : false;
|
||||||
|
|
||||||
|
// Tier-3 / tier-2 providers show verified-provider MiniCards instead of
|
||||||
|
// "more from this provider". Exclude the current provider from the
|
||||||
|
// "similar" list in case we ever add a verified id that collides.
|
||||||
|
const secondaryList =
|
||||||
|
provider.tier === 'verified'
|
||||||
|
? { kind: 'same-provider-more' as const, packages: bundle.other }
|
||||||
|
: {
|
||||||
|
kind: 'nearby-verified' as const,
|
||||||
|
providers: nearbyVerifiedProviders.filter((p) => p.id !== provider.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryHasItems =
|
||||||
|
secondaryList.kind === 'same-provider-more'
|
||||||
|
? secondaryList.packages.length > 0
|
||||||
|
: secondaryList.providers.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={toPackagesStepProvider(provider)}
|
||||||
|
providerTier={provider.tier}
|
||||||
|
packages={bundle.matching}
|
||||||
|
secondaryList={secondaryHasItems ? secondaryList : undefined}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() =>
|
||||||
|
alert(
|
||||||
|
provider.tier === 'verified'
|
||||||
|
? 'Make Arrangement — would route to next wizard step.'
|
||||||
|
: 'Make an enquiry — would open enquiry form.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCompare={handleCompare}
|
||||||
|
isSelectedPackageInCart={isSelectedInCart}
|
||||||
|
onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
navigation={demoNav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
45
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ProvidersStep,
|
||||||
|
EMPTY_FILTER_VALUES,
|
||||||
|
type ProviderFilterValues,
|
||||||
|
type ProviderSortBy,
|
||||||
|
type ListViewMode,
|
||||||
|
} from '../../../../components/pages/ProvidersStep';
|
||||||
|
import { ProviderMap } from '../../../../components/organisms/ProviderMap';
|
||||||
|
import { providers } from '../../../shared/fixtures/providers';
|
||||||
|
import { demoNav } from '../DemoNav';
|
||||||
|
|
||||||
|
export function ProvidersRoute() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
|
||||||
|
const [sort, setSort] = useState<ProviderSortBy>('recommended');
|
||||||
|
const [view, setView] = useState<ListViewMode>('list');
|
||||||
|
|
||||||
|
const filtered = providers.filter((p) => p.location.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={filtered}
|
||||||
|
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
filterValues={filters}
|
||||||
|
onFilterChange={setFilters}
|
||||||
|
sortBy={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
viewMode={view}
|
||||||
|
onViewModeChange={setView}
|
||||||
|
onBack={() => window.history.back()}
|
||||||
|
navigation={demoNav}
|
||||||
|
mapPanel={
|
||||||
|
<ProviderMap
|
||||||
|
providers={filtered}
|
||||||
|
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/demo/shared/assets.ts
Normal file
17
src/demo/shared/assets.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a public-asset path against Vite's base URL.
|
||||||
|
*
|
||||||
|
* In dev `import.meta.env.BASE_URL === '/'`, so `assetUrl('/images/foo.png')`
|
||||||
|
* returns `/images/foo.png` unchanged. In production the build sets base to
|
||||||
|
* `/arrangement/` (or whatever `--mode <slice>` was passed), and the same
|
||||||
|
* call returns `/arrangement/images/foo.png` so the bundled assets resolve
|
||||||
|
* correctly under the slice subpath.
|
||||||
|
*
|
||||||
|
* Always pass leading-slash paths — they're relative to the publicDir root.
|
||||||
|
*/
|
||||||
|
export const assetUrl = (path: string): string => {
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${cleanBase}${cleanPath}`;
|
||||||
|
};
|
||||||
1276
src/demo/shared/fixtures/packages.ts
Normal file
1276
src/demo/shared/fixtures/packages.ts
Normal file
File diff suppressed because it is too large
Load Diff
129
src/demo/shared/fixtures/providers.ts
Normal file
129
src/demo/shared/fixtures/providers.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { ProviderData } from '../../../components/pages/ProvidersStep';
|
||||||
|
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
|
||||||
|
import { assetUrl } from '../assets';
|
||||||
|
|
||||||
|
export interface DemoProvider extends ProviderData {
|
||||||
|
id: string;
|
||||||
|
tier: ProviderTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const providers: DemoProvider[] = [
|
||||||
|
{
|
||||||
|
id: 'parsons',
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
verified: true,
|
||||||
|
tier: 'verified',
|
||||||
|
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-wollongong/01.jpg'),
|
||||||
|
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 7,
|
||||||
|
startingPrice: 1800,
|
||||||
|
distanceKm: 2.3,
|
||||||
|
coords: { lat: -34.1074, lng: 141.9166 },
|
||||||
|
description:
|
||||||
|
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funeral Services',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: true,
|
||||||
|
tier: 'verified',
|
||||||
|
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||||
|
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||||
|
rating: 4.8,
|
||||||
|
reviewCount: 23,
|
||||||
|
startingPrice: 2450,
|
||||||
|
distanceKm: 5.1,
|
||||||
|
coords: { lat: -34.487, lng: 150.897 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wollongong-city',
|
||||||
|
name: 'Wollongong City Funerals',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: false,
|
||||||
|
tier: 'tier3',
|
||||||
|
rating: 4.2,
|
||||||
|
reviewCount: 15,
|
||||||
|
startingPrice: 3400,
|
||||||
|
distanceKm: 6.8,
|
||||||
|
coords: { lat: -34.4278, lng: 150.8931 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'killick',
|
||||||
|
name: 'Killick Family Funerals',
|
||||||
|
location: 'Kingaroy, QLD',
|
||||||
|
verified: true,
|
||||||
|
tier: 'verified',
|
||||||
|
imageUrl: assetUrl('/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg'),
|
||||||
|
logoUrl: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||||
|
rating: 4.9,
|
||||||
|
reviewCount: 15,
|
||||||
|
startingPrice: 3100,
|
||||||
|
distanceKm: 8.4,
|
||||||
|
coords: { lat: -26.5408, lng: 151.8388 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mackay',
|
||||||
|
name: 'Mackay Family Funeral Directors',
|
||||||
|
location: 'Ourimbah, NSW',
|
||||||
|
verified: true,
|
||||||
|
tier: 'verified',
|
||||||
|
imageUrl: assetUrl('/images/venues/mackay-family-garden-estate/01.jpg'),
|
||||||
|
logoUrl: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 87,
|
||||||
|
startingPrice: 2800,
|
||||||
|
distanceKm: 18.2,
|
||||||
|
coords: { lat: -33.3644, lng: 151.3728 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mannings',
|
||||||
|
name: 'Mannings Funerals',
|
||||||
|
location: 'Bega, NSW',
|
||||||
|
verified: true,
|
||||||
|
tier: 'verified',
|
||||||
|
imageUrl: assetUrl('/images/venues/mannings-chapel/01.jpg'),
|
||||||
|
logoUrl: assetUrl('/images/providers/mannings-funerals/logo.png'),
|
||||||
|
rating: 4.7,
|
||||||
|
reviewCount: 31,
|
||||||
|
startingPrice: 2600,
|
||||||
|
distanceKm: 22.0,
|
||||||
|
coords: { lat: -36.6742, lng: 149.8417 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'botanical',
|
||||||
|
name: 'Botanical Funerals',
|
||||||
|
location: 'Newtown, NSW',
|
||||||
|
verified: false,
|
||||||
|
tier: 'tier2',
|
||||||
|
rating: 4.9,
|
||||||
|
reviewCount: 8,
|
||||||
|
startingPrice: 5200,
|
||||||
|
distanceKm: 15.0,
|
||||||
|
coords: { lat: -33.8988, lng: 151.1794 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const providersById: Record<string, DemoProvider> = providers.reduce(
|
||||||
|
(acc, p) => {
|
||||||
|
acc[p.id] = p;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, DemoProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip demo-only fields so the value matches PackagesStepProvider exactly.
|
||||||
|
* (PackagesStepProvider is a structural subset of ProviderData — no `id`, no `tier`.)
|
||||||
|
*/
|
||||||
|
export function toPackagesStepProvider(p: DemoProvider): PackagesStepProvider {
|
||||||
|
return {
|
||||||
|
name: p.name,
|
||||||
|
location: p.location,
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
rating: p.rating,
|
||||||
|
reviewCount: p.reviewCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/demo/shared/state/useBasketUrlSync.ts
Normal file
82
src/demo/shared/state/useBasketUrlSync.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useComparisonBasket } from './useComparisonBasket';
|
||||||
|
|
||||||
|
const PARAM = 'compare';
|
||||||
|
|
||||||
|
const serialise = (keys: string[]): string => keys.join(',');
|
||||||
|
const deserialise = (raw: string | null): string[] =>
|
||||||
|
raw
|
||||||
|
? raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-way sync between the basket store and the `?compare=a:b,c:d` search param.
|
||||||
|
*
|
||||||
|
* Mount once near the router root. URL is the source of truth on initial load
|
||||||
|
* (so a shared link restores the basket); after that, store changes write
|
||||||
|
* through to the URL so the current basket is always shareable.
|
||||||
|
*
|
||||||
|
* In-app navigation from a page that carries `?compare=...` to one that
|
||||||
|
* doesn't (e.g. Back from PackagesStep to the providers map) would drop the
|
||||||
|
* param — to avoid wiping the store, we re-attach the store's keys to the
|
||||||
|
* new URL instead of treating the empty URL as a "clear" signal. External
|
||||||
|
* URL changes that DO carry params still push back into the store (shared
|
||||||
|
* links, manual edits, browser Back after a store write).
|
||||||
|
*/
|
||||||
|
export function useBasketUrlSync(): void {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const initialised = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlKeys = deserialise(searchParams.get(PARAM));
|
||||||
|
const storeKeys = useComparisonBasket.getState().packageKeys;
|
||||||
|
|
||||||
|
if (!initialised.current) {
|
||||||
|
initialised.current = true;
|
||||||
|
if (urlKeys.length > 0 && serialise(urlKeys) !== serialise(storeKeys)) {
|
||||||
|
useComparisonBasket.getState().setAll(urlKeys);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialise(urlKeys) === serialise(storeKeys)) return;
|
||||||
|
|
||||||
|
// URL empty + store has items → in-app navigation dropped the param.
|
||||||
|
// Re-attach the store's keys so the basket stays sticky across routes
|
||||||
|
// (and the current URL remains shareable).
|
||||||
|
if (urlKeys.length === 0 && storeKeys.length > 0) {
|
||||||
|
setSearchParams(
|
||||||
|
(current) => {
|
||||||
|
const next = new URLSearchParams(current);
|
||||||
|
next.set(PARAM, serialise(storeKeys));
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise URL is authoritative (shared link, manual edit, browser Back
|
||||||
|
// after a store write) — push it into the store.
|
||||||
|
useComparisonBasket.getState().setAll(urlKeys);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return useComparisonBasket.subscribe((state, prev) => {
|
||||||
|
if (serialise(state.packageKeys) === serialise(prev.packageKeys)) return;
|
||||||
|
setSearchParams(
|
||||||
|
(current) => {
|
||||||
|
const next = new URLSearchParams(current);
|
||||||
|
if (state.packageKeys.length === 0) next.delete(PARAM);
|
||||||
|
else next.set(PARAM, serialise(state.packageKeys));
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [setSearchParams]);
|
||||||
|
}
|
||||||
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { BasketKey } from '../fixtures/packages';
|
||||||
|
|
||||||
|
// ComparisonPage caps user-selected packages at 3 (recommended is shown as a
|
||||||
|
// separate column). Keep the basket aligned so we can't add a 4th and have it
|
||||||
|
// silently dropped at render time.
|
||||||
|
const MAX_BASKET = 3;
|
||||||
|
|
||||||
|
interface BasketState {
|
||||||
|
packageKeys: BasketKey[];
|
||||||
|
/** Transient feedback message — set when add() is rejected (dupe/full) */
|
||||||
|
lastError: string | null;
|
||||||
|
add: (key: BasketKey) => void;
|
||||||
|
remove: (key: BasketKey) => void;
|
||||||
|
toggle: (key: BasketKey) => void;
|
||||||
|
clear: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
setAll: (keys: BasketKey[]) => void;
|
||||||
|
has: (key: BasketKey) => boolean;
|
||||||
|
isFull: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComparisonBasket = create<BasketState>((set, get) => ({
|
||||||
|
packageKeys: [],
|
||||||
|
lastError: null,
|
||||||
|
add: (key) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.packageKeys.includes(key)) {
|
||||||
|
return { ...state, lastError: 'Already added' };
|
||||||
|
}
|
||||||
|
if (state.packageKeys.length >= MAX_BASKET) {
|
||||||
|
return { ...state, lastError: `Maximum ${MAX_BASKET} packages` };
|
||||||
|
}
|
||||||
|
return { packageKeys: [...state.packageKeys, key], lastError: null };
|
||||||
|
}),
|
||||||
|
remove: (key) => set((state) => ({ packageKeys: state.packageKeys.filter((k) => k !== key) })),
|
||||||
|
toggle: (key) => {
|
||||||
|
const { has, add, remove } = get();
|
||||||
|
if (has(key)) remove(key);
|
||||||
|
else add(key);
|
||||||
|
},
|
||||||
|
clear: () => set({ packageKeys: [], lastError: null }),
|
||||||
|
clearError: () => set({ lastError: null }),
|
||||||
|
setAll: (keys) => set({ packageKeys: keys.slice(0, MAX_BASKET), lastError: null }),
|
||||||
|
has: (key) => get().packageKeys.includes(key),
|
||||||
|
isFull: () => get().packageKeys.length >= MAX_BASKET,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const BASKET_MAX = MAX_BASKET;
|
||||||
50
vite.demo.config.ts
Normal file
50
vite.demo.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-slice demo build. Slice name comes from `--mode <name>` and selects
|
||||||
|
* the app folder, base path, and output directory.
|
||||||
|
*
|
||||||
|
* Dev: vite -c vite.demo.config.ts --mode arrangement
|
||||||
|
* Build: vite build -c vite.demo.config.ts --mode arrangement
|
||||||
|
* → dist-demo/arrangement/
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode, command }) => {
|
||||||
|
const slice = mode;
|
||||||
|
const appRoot = path.resolve(__dirname, `src/demo/apps/${slice}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: appRoot,
|
||||||
|
// Load `.env` / `.env.local` from the repo root. Vite's default is to
|
||||||
|
// read env files from `root`, which here points into `src/demo/apps/...`
|
||||||
|
// where no env files live — so without this VITE_GOOGLE_MAPS_API_KEY
|
||||||
|
// never reaches the built bundle and ProviderMap silently falls back
|
||||||
|
// to its "no API key" empty state in production.
|
||||||
|
envDir: __dirname,
|
||||||
|
// Dev server uses absolute base so HMR/asset URLs work at the root;
|
||||||
|
// production build prefixes assets with /<slice>/ so the bundle is
|
||||||
|
// portable to any nginx location matching that path.
|
||||||
|
base: command === 'build' ? `/${slice}/` : '/',
|
||||||
|
// Mirror Storybook's staticDirs so /brandlogo/, /images/, etc. resolve.
|
||||||
|
publicDir: path.resolve(__dirname, 'brandassets'),
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@atoms': path.resolve(__dirname, 'src/components/atoms'),
|
||||||
|
'@molecules': path.resolve(__dirname, 'src/components/molecules'),
|
||||||
|
'@organisms': path.resolve(__dirname, 'src/components/organisms'),
|
||||||
|
'@theme': path.resolve(__dirname, 'src/theme'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, `dist-demo/${slice}`),
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5180,
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user