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:
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user