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:
281
src/components/molecules/VenueCard/VenueCard.stories.tsx
Normal file
281
src/components/molecules/VenueCard/VenueCard.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
163
src/components/molecules/VenueCard/VenueCard.tsx
Normal file
163
src/components/molecules/VenueCard/VenueCard.tsx
Normal 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;
|
||||
2
src/components/molecules/VenueCard/index.ts
Normal file
2
src/components/molecules/VenueCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { VenueCard, default } from './VenueCard';
|
||||
export type { VenueCardProps } from './VenueCard';
|
||||
@@ -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-height: 24px; /** Track height */
|
||||
--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-100: #f7ecdf; /** Light warm — hover backgrounds, subtle fills */
|
||||
--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-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-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-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 */
|
||||
|
||||
@@ -83,6 +83,9 @@ export const SwitchTrackWidth = "44px"; // Track width — slightly narrower tha
|
||||
export const SwitchTrackHeight = "24px"; // Track height
|
||||
export const SwitchTrackBorderRadius = "9999px"; // Pill shape
|
||||
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 ColorBrand100 = "#f7ecdf"; // Light warm — hover backgrounds, subtle fills
|
||||
export const ColorBrand200 = "#ebdac8"; // Warm light — secondary backgrounds, divider tones
|
||||
|
||||
23
tokens/component/venueCard.json
Normal file
23
tokens/component/venueCard.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user