Add MapPopup molecule — floating card for map pin click context

Compact provider/venue preview anchored to a MapPin. Image + name +
price + meta row + "View details" link. Downward nub connects to pin.
Drop-shadow filter for floating appearance. Verified badge inside image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:51:02 +10:00
parent 4fecb81853
commit ae1e344a8a
3 changed files with 387 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPopup } from './MapPopup';
import { MapPin } from '../../atoms/MapPin';
// Placeholder images
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=200&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=200&fit=crop&auto=format';
const meta: Meta<typeof MapPopup> = {
title: 'Molecules/MapPopup',
component: MapPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
};
export default meta;
type Story = StoryObj<typeof MapPopup>;
/** Verified provider with image, price, location, and rating */
export const VerifiedProvider: Story = {
args: {
name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
rating: 4.8,
verified: true,
onViewDetails: () => {},
},
};
/** Unverified provider — no image, no badge */
export const UnverifiedProvider: Story = {
args: {
name: 'Smith & Sons Funeral Services',
price: 1200,
location: 'Sutherland',
onViewDetails: () => {},
},
};
/** Venue popup — capacity instead of rating */
export const Venue: Story = {
args: {
name: 'Albany Creek Memorial Park — Garden Chapel',
imageUrl: IMG_VENUE,
price: 450,
location: 'Albany Creek',
capacity: 120,
onViewDetails: () => {},
},
};
/** Minimal — just name and view details */
export const Minimal: Story = {
args: {
name: 'Local Funeral Provider',
onViewDetails: () => {},
},
};
/** No view details link — display only */
export const DisplayOnly: Story = {
args: {
name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
verified: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Funeral Services',
imageUrl: IMG_PROVIDER,
priceLabel: 'Price on application',
location: 'Sydney CBD',
verified: true,
onViewDetails: () => {},
},
};
/** Pin + Popup composition — shows how they work together on a map */
export const WithPin: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 400,
height: 380,
bgcolor: '#E5E3DF',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MapPopup
name="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
location="Wollongong"
rating={4.8}
verified
onViewDetails={() => {}}
/>
<MapPin price={900} verified active />
</>
),
};

View File

@@ -0,0 +1,255 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import { Link } from '../../atoms/Link';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPopup molecule */
export interface MapPopupProps {
/** Provider/venue name */
name: string;
/** Hero image URL */
imageUrl?: string;
/** Price in dollars */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity */
capacity?: number;
/** Whether this provider is verified */
verified?: boolean;
/** Badge colour for the verified badge */
badgeColor?: BadgeProps['color'];
/** "View details" click handler */
onViewDetails?: () => void;
/** MUI sx prop for the root Paper */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 260;
const IMAGE_HEIGHT = 100;
const NUB_SIZE = 8;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map popup card for the FA design system.
*
* Floating card anchored to a MapPin on click. Shows a compact
* preview of a provider or venue — image, name, price, meta row,
* and a "View details" link. A downward nub visually connects the
* popup to the pin below.
*
* Designed for use as a custom popup in Mapbox GL / Google Maps.
* The parent map container handles positioning; this component
* handles content and styling only.
*
* Composes: Paper + Typography + Badge + Link.
*
* Usage:
* ```tsx
* <MapPopup
* name="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* price={900}
* location="Wollongong"
* rating={4.8}
* verified
* onViewDetails={() => selectProvider(id)}
* />
* ```
*/
export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
(
{
name,
imageUrl,
price,
priceLabel,
location,
rating,
capacity,
verified = false,
badgeColor = 'brand',
onViewDetails,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
return (
<Box
ref={ref}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
// Offset the popup upward so the nub sits on top of the pin
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{/* ── Image ── */}
{imageUrl && (
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified badge inside image */}
{verified && (
<Box sx={{ position: 'absolute', top: 8, right: 8 }}>
<Badge variant="filled" color={badgeColor} size="small">
Verified
</Badge>
</Box>
)}
</Box>
)}
{/* ── Content ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* Name */}
<Typography variant="body2" sx={{ fontWeight: 600 }} maxLines={2}>
{name}
</Typography>
{/* Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Meta row */}
{hasMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<PeopleOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{capacity}
</Typography>
</Box>
)}
</Box>
)}
{/* Verified badge (no image fallback) */}
{verified && !imageUrl && (
<Badge variant="filled" color={badgeColor} size="small">
Verified
</Badge>
)}
{/* View details link */}
{onViewDetails && (
<Link
component="button"
variant="caption"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onViewDetails();
}}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
>
View details
</Link>
)}
</Box>
</Paper>
{/* Nub — downward pointer connecting to pin */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE}px solid transparent`,
borderRight: `${NUB_SIZE}px solid transparent`,
borderTop: `${NUB_SIZE}px solid`,
borderTopColor: 'background.paper',
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPopup.displayName = 'MapPopup';
export default MapPopup;

View File

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