Refine ProviderCard v2 — logo, price, badges, footer, unverified treatment

- Logo: circle → 64px rounded rectangle (8px radius), positioned fully
  inside image area with white border + shadow
- Footer removed — redundant since whole card is clickable and price
  is already in content area
- Price: split "Packages from" (body2) + price (h6/500wt) for lighter
  ecommerce feel, replaces blocky labelLg/700
- Verified badge bumped to medium size for visibility
- Capability badge: medium size, trailing info icon + capabilityDescription
  tooltip prop for plain-language definitions on hover
- Unverified cards: 3px top accent bar, list on neutral.50 background
- Caption/CaptionSm weight: 400 → 500 system-wide (extends D019)
- Meta row: body2 → caption size for clearer tertiary hierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 18:28:28 +11:00
parent 811736dbb9
commit 891ded2fdb
9 changed files with 160 additions and 101 deletions

View File

@@ -2,18 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react';
import { ProviderCard } from './ProviderCard';
import Box from '@mui/material/Box';
// Placeholder images for stories (grey boxes via data URIs)
// Placeholder images for stories
const HERO_PLACEHOLDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_2 =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_3 =
'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=600&h=300&fit=crop&auto=format';
// Simple grey circle for logo placeholder
// Rounded-rect logo placeholder (matches new logo shape)
const LOGO_PLACEHOLDER =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="48" fill="%23E8E8E8"/><text x="48" y="54" text-anchor="middle" font-family="sans-serif" font-size="14" fill="%23737373">Logo</text></svg>',
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="12" fill="%23E8E8E8"/><text x="48" y="54" text-anchor="middle" font-family="sans-serif" font-size="14" fill="%23737373">Logo</text></svg>',
);
const meta: Meta<typeof ProviderCard> = {
@@ -38,6 +38,7 @@ const meta: Meta<typeof ProviderCard> = {
control: 'select',
options: ['default', 'success', 'warning', 'error', 'info'],
},
capabilityDescription: { control: 'text' },
startingPrice: { control: 'number' },
onClick: { action: 'clicked' },
},
@@ -67,6 +68,7 @@ export const Default: Story = {
reviewCount: 127,
capabilityLabel: 'Online Arrangement',
capabilityColor: 'success',
capabilityDescription: 'Complete your arrangement entirely online — no in-person visit required.',
startingPrice: 900,
},
};
@@ -87,6 +89,7 @@ export const VerifiedProvider: Story = {
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
@@ -95,7 +98,7 @@ export const VerifiedProvider: Story = {
// ─── Unverified Provider ────────────────────────────────────────────────────
/** Unverified provider — text only, no image/logo/trusted badge */
/** Unverified provider — text only with top accent bar, no image/logo/trusted badge */
export const UnverifiedProvider: Story = {
name: 'Unverified Provider',
render: () => (
@@ -106,6 +109,7 @@ export const UnverifiedProvider: Story = {
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={1200}
onClick={() => {}}
/>
@@ -115,15 +119,25 @@ export const UnverifiedProvider: Story = {
// ─── List Layout ────────────────────────────────────────────────────────────
/**
* Mixed verified and unverified providers in a scrollable list.
* This is the primary use case — text alignment should be consistent
* across both card types for scan readability.
* Mixed verified and unverified providers in a scrollable list on
* neutral.50 background. This is the primary use case — text alignment
* should be consistent across both card types for scan readability.
* Unverified cards have a top accent bar for visibility.
*/
export const ListLayout: Story = {
name: 'List Layout — Mixed',
decorators: [
(Story) => (
<Box sx={{ width: 400, maxHeight: 700, overflow: 'auto' }}>
<Box
sx={{
width: 400,
maxHeight: 700,
overflow: 'auto',
backgroundColor: 'var(--fa-color-surface-subtle)',
p: 2,
borderRadius: 1,
}}
>
<Story />
</Box>
),
@@ -140,6 +154,7 @@ export const ListLayout: Story = {
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
@@ -153,6 +168,7 @@ export const ListLayout: Story = {
reviewCount={89}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={1100}
onClick={() => {}}
/>
@@ -163,6 +179,7 @@ export const ListLayout: Story = {
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={1200}
onClick={() => {}}
/>
@@ -173,6 +190,7 @@ export const ListLayout: Story = {
reviewCount={18}
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
capabilityDescription="Some steps can be completed online, but an in-person visit is required to finalise."
startingPrice={950}
onClick={() => {}}
/>
@@ -186,6 +204,7 @@ export const ListLayout: Story = {
reviewCount={203}
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={1500}
onClick={() => {}}
/>
@@ -195,7 +214,7 @@ export const ListLayout: Story = {
// ─── Capability Variants ────────────────────────────────────────────────────
/** Three capability badge colours */
/** Three capability badge colours with hover tooltips */
export const CapabilityVariants: Story = {
name: 'Capability Variants',
decorators: [
@@ -212,6 +231,7 @@ export const CapabilityVariants: Story = {
location="Sydney"
capabilityLabel="Online Arrangement"
capabilityColor="success"
capabilityDescription="Complete your arrangement entirely online — no in-person visit required."
startingPrice={900}
onClick={() => {}}
/>
@@ -220,6 +240,7 @@ export const CapabilityVariants: Story = {
location="Melbourne"
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
capabilityDescription="Some steps can be completed online, but an in-person visit is required to finalise."
startingPrice={1100}
onClick={() => {}}
/>
@@ -228,6 +249,7 @@ export const CapabilityVariants: Story = {
location="Brisbane"
capabilityLabel="Outsourced"
capabilityColor="default"
capabilityDescription="This provider uses a third-party service to manage arrangements."
startingPrice={800}
onClick={() => {}}
/>
@@ -278,7 +300,7 @@ export const EdgeCases: Story = {
startingPrice={600}
onClick={() => {}}
/>
{/* No price (footer hidden) */}
{/* No price */}
<ProviderCard
name="Price Unavailable"
location="Darwin"
@@ -335,7 +357,7 @@ export const Responsive: Story = {
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Cards on white vs grey surfaces */
/** Cards on white vs grey surfaces — unverified on grey is the expected usage */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
decorators: [

View File

@@ -1,10 +1,11 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { Card } from '../../atoms/Card';
import { Badge } from '../../atoms/Badge';
import type { BadgeColor } from '../../atoms/Badge/Badge';
@@ -22,7 +23,7 @@ export interface ProviderCardProps {
verified?: boolean;
/** Hero image URL — only rendered when verified */
imageUrl?: string;
/** Provider logo URL — circular overlay on image, only rendered when verified */
/** Provider logo URL — rounded rectangle overlay on image, only rendered when verified */
logoUrl?: string;
/** Average rating (e.g. 4.8). Omit to hide reviews. */
rating?: number;
@@ -32,7 +33,9 @@ export interface ProviderCardProps {
capabilityLabel?: string;
/** Capability badge colour intent — maps to Badge colour */
capabilityColor?: BadgeColor;
/** Starting price in dollars (shown in footer as "Packages from $X") */
/** Tooltip description for the capability badge (shown on hover/focus) */
capabilityDescription?: string;
/** Starting price in dollars (shown as "From $X") */
startingPrice?: number;
/** Click handler — entire card is clickable */
onClick?: () => void;
@@ -43,13 +46,10 @@ export interface ProviderCardProps {
// ─── Constants ───────────────────────────────────────────────────────────────
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
const LOGO_OVERLAP = 28; // half of 56px logo, in px
const LOGO_BORDER_RADIUS = 'var(--fa-provider-card-logo-border-radius)';
const IMAGE_HEIGHT = 'var(--fa-provider-card-image-height)';
const CONTENT_PADDING = 'var(--fa-provider-card-content-padding)';
const CONTENT_GAP = 'var(--fa-provider-card-content-gap)';
const FOOTER_BG = 'var(--fa-provider-card-footer-background)';
const FOOTER_PX = 'var(--fa-provider-card-footer-padding-x)';
const FOOTER_PY = 'var(--fa-provider-card-footer-padding-y)';
// ─── Component ───────────────────────────────────────────────────────────────
@@ -60,9 +60,9 @@ const FOOTER_PY = 'var(--fa-provider-card-footer-padding-y)';
* list. Supports verified (paid partner) and unverified (scraped listing)
* providers with consistent text alignment for scan readability.
*
* **Verified providers** get a hero image, logo overlay, and "Verified"
* badge. **Unverified providers** show text content only — no image, logo,
* or verification badge.
* **Verified providers** get a hero image, logo (rounded rectangle inside
* image area), and "Verified" badge. **Unverified providers** show text
* content only with a subtle top accent bar for visibility in mixed lists.
*
* Composes: Card (interactive, padding="none"), Badge, Typography.
*
@@ -95,6 +95,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
reviewCount,
capabilityLabel,
capabilityColor = 'default',
capabilityDescription,
startingPrice,
onClick,
sx,
@@ -118,6 +119,11 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
'&:hover': {
backgroundColor: 'background.paper',
},
// Unverified cards: subtle top accent so they don't get lost
// in a mixed list. Verified cards have the hero image as anchor.
...(!showImage && {
borderTop: '3px solid var(--fa-color-border-default)',
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
@@ -139,14 +145,14 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
<Badge
variant="filled"
color="brand"
size="small"
size="medium"
icon={<VerifiedOutlinedIcon />}
>
Verified
</Badge>
</Box>
{/* Logo overlay */}
{/* Logo — fully inside image area, bottom-left */}
{showLogo && (
<Box
component="img"
@@ -154,14 +160,15 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
alt={`${name} logo`}
sx={{
position: 'absolute',
bottom: -LOGO_OVERLAP,
bottom: 12,
left: CONTENT_PADDING,
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: '50%',
borderRadius: LOGO_BORDER_RADIUS,
objectFit: 'cover',
backgroundColor: 'background.paper',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
border: '2px solid white',
}}
/>
)}
@@ -175,20 +182,28 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
// Extra top padding when logo overlaps into content
...(showLogo && { pt: `calc(${CONTENT_PADDING} + ${LOGO_OVERLAP}px)` }),
}}
>
{/* Provider name */}
{/* Provider name — full width, no logo competition */}
<Typography variant="h5" maxLines={2}>
{name}
</Typography>
{/* Price — primary comparison data, prominent position */}
{/* Price — "Packages from $X" with subtle size differentiation */}
{startingPrice != null && (
<Typography variant="labelLg" sx={{ fontWeight: 700 }} color="primary">
From ${startingPrice.toLocaleString('en-AU')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Packages from
</Typography>
<Typography
variant="h6"
component="span"
color="primary"
sx={{ fontWeight: 500, letterSpacing: '-0.01em' }}
>
${startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
{/* Meta row: location + reviews */}
@@ -203,9 +218,9 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
{/* Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary' }}
sx={{ fontSize: 14, color: 'text.secondary' }}
/>
<Typography variant="body2" color="text.secondary">
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
@@ -217,10 +232,10 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
aria-label={`Rated ${rating} out of 5${reviewCount != null ? `, ${reviewCount} reviews` : ''}`}
>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'warning.main' }}
sx={{ fontSize: 14, color: 'warning.main' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
<Typography variant="caption" color="text.secondary">
{rating}
{reviewCount != null && ` (${reviewCount.toLocaleString('en-AU')})`}
</Typography>
@@ -228,35 +243,34 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
)}
</Box>
{/* Capability badge */}
{/* Capability badge — trailing info icon signals hover-for-definition */}
{capabilityLabel && (
<Box>
<Badge color={capabilityColor} size="small">
{capabilityLabel}
</Badge>
{capabilityDescription ? (
<Tooltip
title={capabilityDescription}
arrow
placement="top"
enterTouchDelay={0}
>
<Badge
color={capabilityColor}
size="medium"
sx={{ cursor: 'help' }}
>
{capabilityLabel}
<InfoOutlinedIcon />
</Badge>
</Tooltip>
) : (
<Badge color={capabilityColor} size="medium">
{capabilityLabel}
<InfoOutlinedIcon />
</Badge>
)}
</Box>
)}
</Box>
{/* ── Footer bar ── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: FOOTER_BG,
px: FOOTER_PX,
py: FOOTER_PY,
}}
>
<Typography variant="label" color="text.secondary">
View packages
</Typography>
<ChevronRightIcon
sx={{ fontSize: 20, color: 'text.secondary' }}
aria-hidden
/>
</Box>
</Card>
);
},