Add VenueCard molecule — venue listing card for service venue select screen

- 3 component tokens (image.height, content.padding, content.gap)
- Composes Card (interactive) + Typography, consistent with ProviderCard patterns
- Hero image with role="img" aria-label for screen readers
- Meta row: location (pin icon) + capacity with "guests" suffix for clarity
- Price with "From" qualifier for transparency (split typography like ProviderCard)
- 6 Storybook stories: Default, ListLayout, EdgeCases, Responsive, OnDifferentBackgrounds, InteractiveDemo
- Critique score: 33/40 (Good) — P2 fixes applied (capacity label, price context, image a11y)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 19:51:53 +11:00
parent 891ded2fdb
commit 9323b52376
6 changed files with 475 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
import type { Meta, StoryObj } from '@storybook/react';
import { VenueCard } from './VenueCard';
import Box from '@mui/material/Box';
// Venue photo placeholders
const VENUE_CHAPEL =
'https://images.unsplash.com/photo-1545128485-c400e7702796?w=600&h=300&fit=crop&auto=format';
const VENUE_GARDEN =
'https://images.unsplash.com/photo-1510076857177-7470076d4098?w=600&h=300&fit=crop&auto=format';
const VENUE_HALL =
'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=600&h=300&fit=crop&auto=format';
const VENUE_CHURCH =
'https://images.unsplash.com/photo-1438032005730-c779502df39b?w=600&h=300&fit=crop&auto=format';
const VENUE_BEACH =
'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=600&h=300&fit=crop&auto=format';
const meta: Meta<typeof VenueCard> = {
title: 'Molecules/VenueCard',
component: VenueCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2997-83018',
},
},
argTypes: {
name: { control: 'text' },
location: { control: 'text' },
imageUrl: { control: 'text' },
capacity: { control: 'number' },
price: { control: 'number' },
onClick: { action: 'clicked' },
},
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof VenueCard>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — venue with all fields */
export const Default: Story = {
args: {
name: 'West Chapel',
imageUrl: VENUE_CHAPEL,
location: 'Strathfield',
capacity: 100,
price: 900,
},
};
// ─── List Layout ────────────────────────────────────────────────────────────
/**
* Multiple venue cards in a scrollable list on neutral.50 background.
* This is the primary use case — the venue select screen's left panel.
*/
export const ListLayout: Story = {
name: 'List Layout',
decorators: [
(Story) => (
<Box
sx={{
width: 400,
maxHeight: 700,
overflow: 'auto',
backgroundColor: 'var(--fa-color-surface-subtle)',
p: 2,
borderRadius: 1,
}}
>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<VenueCard
name="West Chapel"
imageUrl={VENUE_CHAPEL}
location="Strathfield"
capacity={100}
price={900}
onClick={() => {}}
/>
<VenueCard
name="Macquarie Park Memorial Gardens"
imageUrl={VENUE_GARDEN}
location="Macquarie Park"
capacity={250}
price={1200}
onClick={() => {}}
/>
<VenueCard
name="Wollongong Community Hall"
imageUrl={VENUE_HALL}
location="Wollongong"
capacity={300}
price={750}
onClick={() => {}}
/>
<VenueCard
name="St. Mary's Church"
imageUrl={VENUE_CHURCH}
location="Corrimal"
capacity={150}
price={600}
onClick={() => {}}
/>
<VenueCard
name="Thirroul Beach Pavilion"
imageUrl={VENUE_BEACH}
location="Thirroul"
capacity={80}
price={1500}
onClick={() => {}}
/>
</Box>
),
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases: long name, missing optional fields, extremes */
export const EdgeCases: Story = {
name: 'Edge Cases',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Long name — tests maxLines truncation */}
<VenueCard
name="The Most Honourable Memorial Gardens and Reception Centre of Greater Wollongong"
imageUrl={VENUE_GARDEN}
location="Wollongong"
capacity={500}
price={2500}
onClick={() => {}}
/>
{/* No capacity */}
<VenueCard
name="Private Residence"
imageUrl={VENUE_CHAPEL}
location="Austinmer"
price={0}
onClick={() => {}}
/>
{/* No price */}
<VenueCard
name="Price On Application"
imageUrl={VENUE_HALL}
location="Sydney CBD"
capacity={400}
onClick={() => {}}
/>
{/* Minimal — just name, image, location */}
<VenueCard
name="Minimal Venue"
imageUrl={VENUE_BEACH}
location="Kiama"
onClick={() => {}}
/>
</Box>
),
};
// ─── Responsive ─────────────────────────────────────────────────────────────
/** Cards at different viewport widths */
export const Responsive: Story = {
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'start', flexWrap: 'wrap' }}>
{[280, 340, 420].map((width) => (
<Box key={width} sx={{ width }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>
{width}px
</Box>
<VenueCard
name="West Chapel"
imageUrl={VENUE_CHAPEL}
location="Strathfield"
capacity={100}
price={900}
onClick={() => {}}
/>
</Box>
))}
</Box>
),
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Cards on white vs grey surfaces */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ width: 360, p: 3, backgroundColor: 'background.default' }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>
White surface
</Box>
<VenueCard
name="West Chapel"
imageUrl={VENUE_CHAPEL}
location="Strathfield"
capacity={100}
price={900}
onClick={() => {}}
/>
</Box>
<Box
sx={{
width: 360,
p: 3,
backgroundColor: 'var(--fa-color-surface-subtle)',
}}
>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>
Grey surface (neutral.50)
</Box>
<VenueCard
name="Macquarie Park Gardens"
imageUrl={VENUE_GARDEN}
location="Macquarie Park"
capacity={250}
price={1200}
onClick={() => {}}
/>
</Box>
</Box>
),
};
// ─── Interactive Demo ───────────────────────────────────────────────────────
/** Click any card — fires onClick in Storybook actions panel */
export const InteractiveDemo: Story = {
name: 'Interactive — Click to Select',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<VenueCard
name="West Chapel"
imageUrl={VENUE_CHAPEL}
location="Strathfield"
capacity={100}
price={900}
onClick={() => alert('Selected: West Chapel')}
/>
<VenueCard
name="Macquarie Park Memorial Gardens"
imageUrl={VENUE_GARDEN}
location="Macquarie Park"
capacity={250}
price={1200}
onClick={() => alert('Selected: Macquarie Park')}
/>
</Box>
),
};

View File

@@ -0,0 +1,163 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA VenueCard molecule */
export interface VenueCardProps {
/** Venue display name */
name: string;
/** Venue photo URL — always present for venues */
imageUrl: string;
/** Location text (suburb, city) */
location: string;
/** Venue capacity (seating/standing) */
capacity?: number;
/** Venue hire price in dollars */
price?: number;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const IMAGE_HEIGHT = 'var(--fa-venue-card-image-height)';
const CONTENT_PADDING = 'var(--fa-venue-card-content-padding)';
const CONTENT_GAP = 'var(--fa-venue-card-content-gap)';
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Venue listing card for the FA design system.
*
* Displays a service venue in the venue select screen's scrollable list.
* Venues always have a photo, location, and typically capacity + price.
* Simpler than ProviderCard — no verification tiers, no logo, no
* capability badges.
*
* Composes: Card (interactive, padding="none"), Typography.
*
* Usage:
* ```tsx
* <VenueCard
* name="West Chapel"
* imageUrl="/images/west-chapel.jpg"
* location="Strathfield"
* capacity={100}
* price={900}
* onClick={() => navigate(`/venues/west-chapel`)}
* />
* ```
*/
export const VenueCard = React.forwardRef<HTMLDivElement, VenueCardProps>(
({ name, imageUrl, location, capacity, price, onClick, sx }, ref) => {
return (
<Card
ref={ref}
interactive
padding="none"
onClick={onClick}
sx={[
{
overflow: 'hidden',
'&:hover': {
backgroundColor: 'background.paper',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image area ── */}
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
}}
/>
{/* ── Content area ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* Venue name */}
<Typography variant="h5" maxLines={2}>
{name}
</Typography>
{/* Meta row: location + capacity */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
}}
>
{/* Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
/>
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
{/* Capacity */}
{capacity != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Capacity: ${capacity} guests`}
>
<PeopleOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{capacity} guests
</Typography>
</Box>
)}
</Box>
{/* Price */}
{price != null && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mt: 0.5 }}>
<Typography variant="body2" color="text.secondary">
From
</Typography>
<Typography
variant="h6"
component="span"
color="primary"
sx={{ fontWeight: 600 }}
>
${price.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</Box>
</Card>
);
},
);
VenueCard.displayName = 'VenueCard';
export default VenueCard;

View File

@@ -0,0 +1,2 @@
export { VenueCard, default } from './VenueCard';
export type { VenueCardProps } from './VenueCard';

View File

@@ -33,6 +33,7 @@
--fa-switch-track-width: 44px; /** Track width — slightly narrower than Figma 52px for better proportion with 44px touch target */ --fa-switch-track-width: 44px; /** Track width — slightly narrower than Figma 52px for better proportion with 44px touch target */
--fa-switch-track-height: 24px; /** Track height */ --fa-switch-track-height: 24px; /** Track height */
--fa-switch-thumb-size: 18px; /** Thumb diameter — sits inside the track with 3px inset */ --fa-switch-thumb-size: 18px; /** Thumb diameter — sits inside the track with 3px inset */
--fa-venue-card-image-height: 180px; /** Fixed image height — matches ProviderCard for consistent list layout when both card types appear in search results */
--fa-color-brand-50: #fef9f5; /** Lightest warm tint — warm section backgrounds */ --fa-color-brand-50: #fef9f5; /** Lightest warm tint — warm section backgrounds */
--fa-color-brand-100: #f7ecdf; /** Light warm — hover backgrounds, subtle fills */ --fa-color-brand-100: #f7ecdf; /** Light warm — hover backgrounds, subtle fills */
--fa-color-brand-200: #ebdac8; /** Warm light — secondary backgrounds, divider tones */ --fa-color-brand-200: #ebdac8; /** Warm light — secondary backgrounds, divider tones */
@@ -271,6 +272,8 @@
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */ --fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */ --fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
--fa-switch-track-border-radius: var(--fa-border-radius-full); /** Pill shape */ --fa-switch-track-border-radius: var(--fa-border-radius-full); /** Pill shape */
--fa-venue-card-content-padding: var(--fa-spacing-3); /** 12px content padding — matches ProviderCard for visual consistency */
--fa-venue-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
--fa-color-text-primary: var(--fa-color-neutral-800); /** Primary text — body content, headings. Cool charcoal (#2C2E35) for comfortable extended reading */ --fa-color-text-primary: var(--fa-color-neutral-800); /** Primary text — body content, headings. Cool charcoal (#2C2E35) for comfortable extended reading */
--fa-color-text-secondary: var(--fa-color-neutral-600); /** Secondary text — helper text, descriptions, metadata, less prominent content */ --fa-color-text-secondary: var(--fa-color-neutral-600); /** Secondary text — helper text, descriptions, metadata, less prominent content */
--fa-color-text-tertiary: var(--fa-color-neutral-500); /** Tertiary text — placeholders, timestamps, attribution, meta information */ --fa-color-text-tertiary: var(--fa-color-neutral-500); /** Tertiary text — placeholders, timestamps, attribution, meta information */

View File

@@ -83,6 +83,9 @@ export const SwitchTrackWidth = "44px"; // Track width — slightly narrower tha
export const SwitchTrackHeight = "24px"; // Track height export const SwitchTrackHeight = "24px"; // Track height
export const SwitchTrackBorderRadius = "9999px"; // Pill shape export const SwitchTrackBorderRadius = "9999px"; // Pill shape
export const SwitchThumbSize = "18px"; // Thumb diameter — sits inside the track with 3px inset export const SwitchThumbSize = "18px"; // Thumb diameter — sits inside the track with 3px inset
export const VenueCardImageHeight = "180px"; // Fixed image height — matches ProviderCard for consistent list layout when both card types appear in search results
export const VenueCardContentPadding = "12px"; // 12px content padding — matches ProviderCard for visual consistency
export const VenueCardContentGap = "4px"; // 4px vertical gap between content rows — tight for compact listing cards
export const ColorBrand50 = "#fef9f5"; // Lightest warm tint — warm section backgrounds export const ColorBrand50 = "#fef9f5"; // Lightest warm tint — warm section backgrounds
export const ColorBrand100 = "#f7ecdf"; // Light warm — hover backgrounds, subtle fills export const ColorBrand100 = "#f7ecdf"; // Light warm — hover backgrounds, subtle fills
export const ColorBrand200 = "#ebdac8"; // Warm light — secondary backgrounds, divider tones export const ColorBrand200 = "#ebdac8"; // Warm light — secondary backgrounds, divider tones

View File

@@ -0,0 +1,23 @@
{
"venueCard": {
"$description": "VenueCard molecule tokens — listing card for service venues on the venue select screen. Always has a photo, location, capacity, and price. Simpler than ProviderCard — no verification tiers or logo.",
"image": {
"$type": "dimension",
"$description": "Hero image area dimensions.",
"height": { "$value": "180px", "$description": "Fixed image height — matches ProviderCard for consistent list layout when both card types appear in search results" }
},
"content": {
"$description": "Content area spacing.",
"padding": {
"$type": "dimension",
"$value": "{spacing.3}",
"$description": "12px content padding — matches ProviderCard for visual consistency"
},
"gap": {
"$type": "dimension",
"$value": "{spacing.1}",
"$description": "4px vertical gap between content rows — tight for compact listing cards"
}
}
}
}