diff --git a/src/components/molecules/MiniCard/MiniCard.stories.tsx b/src/components/molecules/MiniCard/MiniCard.stories.tsx new file mode 100644 index 0000000..c95bb82 --- /dev/null +++ b/src/components/molecules/MiniCard/MiniCard.stories.tsx @@ -0,0 +1,157 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined'; +import { MiniCard } from './MiniCard'; + +// Placeholder images for stories +const IMG_PROVIDER = + 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format'; +const IMG_VENUE = + 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format'; +const IMG_CHAPEL = + 'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format'; +const IMG_GARDEN = + 'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format'; + +const meta: Meta = { + title: 'Molecules/MiniCard', + component: MiniCard, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Default mini card with title, image, and price */ +export const Default: Story = { + args: { + title: 'H.Parsons Funeral Directors', + imageUrl: IMG_PROVIDER, + price: 900, + location: 'Wollongong', + }, +}; + +/** With all optional fields populated */ +export const FullyLoaded: Story = { + args: { + title: 'H.Parsons Funeral Directors', + imageUrl: IMG_PROVIDER, + price: 900, + location: 'Wollongong', + rating: 4.8, + badges: [ + { label: 'Verified', color: 'brand', variant: 'filled', icon: }, + { label: 'Online Arrangement', color: 'success' }, + ], + chips: ['Burial', 'Cremation'], + }, +}; + +/** Venue card usage — capacity instead of rating */ +export const Venue: Story = { + args: { + title: 'Albany Creek Memorial Park', + imageUrl: IMG_CHAPEL, + price: 450, + location: 'Albany Creek', + capacity: 120, + }, +}; + +/** Package card usage — custom price label */ +export const Package: Story = { + args: { + title: 'Essential Cremation Package', + imageUrl: IMG_GARDEN, + priceLabel: 'From $2,800', + badges: [{ label: 'Most Popular', color: 'brand' }], + }, +}; + +/** Minimal — just title and image */ +export const Minimal: Story = { + args: { + title: 'Lady Anne Funerals', + imageUrl: IMG_VENUE, + }, +}; + +/** Selected state — brand border + warm background */ +export const Selected: Story = { + args: { + title: 'H.Parsons Funeral Directors', + imageUrl: IMG_PROVIDER, + price: 900, + location: 'Wollongong', + selected: true, + }, +}; + +/** Long title truncated at 2 lines */ +export const LongTitle: Story = { + args: { + title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services', + imageUrl: IMG_GARDEN, + price: 1200, + location: 'Northern Beaches', + rating: 4.9, + }, +}; + +/** Multiple cards in a responsive grid */ +export const Grid: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: () => ( + <> + }, + ]} + onClick={() => {}} + /> + {}} + /> + {}} + /> + + ), +}; diff --git a/src/components/molecules/MiniCard/MiniCard.tsx b/src/components/molecules/MiniCard/MiniCard.tsx new file mode 100644 index 0000000..db4c6ae --- /dev/null +++ b/src/components/molecules/MiniCard/MiniCard.tsx @@ -0,0 +1,259 @@ +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 StarRoundedIcon from '@mui/icons-material/StarRounded'; +import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'; +import { Card } from '../../atoms/Card'; +import { Typography } from '../../atoms/Typography'; +import { Badge } from '../../atoms/Badge'; +import type { BadgeProps } from '../../atoms/Badge/Badge'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** A badge to render inside the MiniCard */ +export interface MiniCardBadge { + /** Label text */ + label: string; + /** Badge colour intent */ + color?: BadgeProps['color']; + /** Badge variant */ + variant?: BadgeProps['variant']; + /** Optional leading icon */ + icon?: React.ReactNode; +} + +/** Props for the FA MiniCard molecule */ +export interface MiniCardProps { + /** Card title — provider name, venue name, package name, etc. */ + title: string; + /** Hero image URL */ + imageUrl: string; + /** Alt text for the image — defaults to title */ + imageAlt?: string; + /** Price in dollars — shown as "From $X" */ + price?: number; + /** Custom price label (e.g. "POA", "Included") — overrides formatted price */ + priceLabel?: string; + /** Location text (suburb, city) */ + location?: string; + /** Average rating (e.g. 4.8) */ + rating?: number; + /** Venue capacity (e.g. 120) */ + capacity?: number; + /** Badge items to render below the title */ + badges?: MiniCardBadge[]; + /** Chip labels rendered as small soft badges */ + chips?: string[]; + /** Whether this card is currently selected */ + selected?: boolean; + /** Click handler — entire card is clickable */ + onClick?: () => void; + /** MUI sx prop for style overrides */ + sx?: SxProps; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)'; +const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)'; +const CONTENT_GAP = 'var(--fa-mini-card-content-gap)'; + +// ─── Component ────────────────────────────────────────────────────────────── + +/** + * Compact vertical card for the FA design system. + * + * A smaller, flexible card for displaying providers, venues, or packages + * in grids, recommendation rows, and map popups. Shows an image with + * a title and optional price, badges, chips, and meta row. + * + * Lighter than ProviderCard — no verified/unverified tiers, no logo + * overlay, no capability tooltips. Just image + content. + * + * Composes: Card + Typography + Badge. + * + * Usage: + * ```tsx + * navigate('/providers/parsons')} + * /> + * ``` + */ +export const MiniCard = React.forwardRef( + ( + { + title, + imageUrl, + imageAlt, + price, + priceLabel, + location, + rating, + capacity, + badges, + chips, + selected = false, + onClick, + sx, + }, + ref, + ) => { + const hasMeta = location != null || rating != null || capacity != null; + const hasPrice = price != null || priceLabel != null; + + return ( + + {/* ── Image ── */} + + + {/* ── Content ── */} + + {/* Title */} + + {title} + + + {/* Price */} + {hasPrice && ( + + {priceLabel ? ( + + {priceLabel} + + ) : ( + <> + + From + + + ${price!.toLocaleString('en-AU')} + + + )} + + )} + + {/* Badges */} + {badges && badges.length > 0 && ( + + {badges.map((badge) => ( + + {badge.label} + + ))} + + )} + + {/* Chips */} + {chips && chips.length > 0 && ( + + {chips.map((chip) => ( + + {chip} + + ))} + + )} + + {/* Meta row: location / rating / capacity */} + {hasMeta && ( + + {location && ( + + + + {location} + + + )} + + {rating != null && ( + + + + {rating} + + + )} + + {capacity != null && ( + + + + {capacity} guests + + + )} + + )} + + + ); + }, +); + +MiniCard.displayName = 'MiniCard'; +export default MiniCard; diff --git a/src/components/molecules/MiniCard/index.ts b/src/components/molecules/MiniCard/index.ts new file mode 100644 index 0000000..48261c3 --- /dev/null +++ b/src/components/molecules/MiniCard/index.ts @@ -0,0 +1,2 @@ +export { MiniCard, default } from './MiniCard'; +export type { MiniCardProps, MiniCardBadge } from './MiniCard'; diff --git a/src/theme/generated/tokens.css b/src/theme/generated/tokens.css index ea2fb0b..02d8d55 100644 --- a/src/theme/generated/tokens.css +++ b/src/theme/generated/tokens.css @@ -26,6 +26,11 @@ --fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */ --fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */ --fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */ + --fa-map-pin-height: 28px; /** Pill height — compact for map density */ + --fa-map-pin-font-size: 12px; /** Small but legible price text */ + --fa-map-pin-dot-size: 12px; /** Small circle marker */ + --fa-map-pin-nub-size: 6px; /** Nub triangle size */ + --fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */ --fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */ --fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */ --fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */ @@ -268,6 +273,10 @@ --fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */ --fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */ --fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */ + --fa-map-pin-padding-x: var(--fa-spacing-2); /** 8px horizontal padding inside pill */ + --fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */ + --fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */ + --fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */ --fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */ --fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */ --fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */ diff --git a/src/theme/generated/tokens.d.ts b/src/theme/generated/tokens.d.ts index 7a921ce..cb0b559 100644 --- a/src/theme/generated/tokens.d.ts +++ b/src/theme/generated/tokens.d.ts @@ -71,6 +71,15 @@ export declare const InputFontSizeDefault: string; export declare const InputBorderRadiusDefault: string; export declare const InputGapDefault: string; export declare const InputIconSizeDefault: string; +export declare const MapPinHeight: string; +export declare const MapPinPaddingX: string; +export declare const MapPinFontSize: string; +export declare const MapPinBorderRadius: string; +export declare const MapPinDotSize: string; +export declare const MapPinNubSize: string; +export declare const MiniCardImageHeight: string; +export declare const MiniCardContentPadding: string; +export declare const MiniCardContentGap: string; export declare const ProviderCardImageHeight: string; export declare const ProviderCardLogoSize: string; export declare const ProviderCardLogoBorderRadius: string; diff --git a/src/theme/generated/tokens.js b/src/theme/generated/tokens.js index 988c527..ed132ba 100644 --- a/src/theme/generated/tokens.js +++ b/src/theme/generated/tokens.js @@ -72,6 +72,15 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon +export const MapPinHeight = "28px"; // Pill height — compact for map density +export const MapPinPaddingX = "8px"; // 8px horizontal padding inside pill +export const MapPinFontSize = "12px"; // Small but legible price text +export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape +export const MapPinDotSize = "12px"; // Small circle marker +export const MapPinNubSize = "6px"; // Nub triangle size +export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids +export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding +export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius diff --git a/tokens/component/mapPin.json b/tokens/component/mapPin.json new file mode 100644 index 0000000..fb1ac63 --- /dev/null +++ b/tokens/component/mapPin.json @@ -0,0 +1,17 @@ +{ + "mapPin": { + "$description": "MapPin atom tokens — price-pill map markers for provider/venue map views. Verified (brand) vs unverified (neutral) visual distinction.", + "height": { "$type": "dimension", "$value": "28px", "$description": "Pill height — compact for map density" }, + "paddingX": { "$type": "dimension", "$value": "{spacing.2}", "$description": "8px horizontal padding inside pill" }, + "fontSize": { "$type": "dimension", "$value": "12px", "$description": "Small but legible price text" }, + "borderRadius": { "$type": "dimension", "$value": "{borderRadius.full}", "$description": "Fully rounded pill shape" }, + "dot": { + "$description": "Dot variant for pins without a price label.", + "size": { "$type": "dimension", "$value": "12px", "$description": "Small circle marker" } + }, + "nub": { + "$description": "Downward-pointing nub anchoring the pill to the map location.", + "size": { "$type": "dimension", "$value": "6px", "$description": "Nub triangle size" } + } + } +} diff --git a/tokens/component/miniCard.json b/tokens/component/miniCard.json new file mode 100644 index 0000000..1822fd3 --- /dev/null +++ b/tokens/component/miniCard.json @@ -0,0 +1,15 @@ +{ + "miniCard": { + "$description": "MiniCard molecule tokens — compact vertical card for providers, venues, packages in grids, recommendations, and map popups.", + "image": { + "$type": "dimension", + "$description": "Hero image area dimensions.", + "height": { "$value": "120px", "$description": "Shorter image than full listing cards (180px) for compact grids" } + }, + "content": { + "$description": "Content area spacing.", + "padding": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px — matches ProviderCard/VenueCard content padding" }, + "gap": { "$type": "dimension", "$value": "{spacing.1}", "$description": "4px vertical gap between content rows" } + } + } +}