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:
130
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal file
130
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
255
src/components/molecules/MapPopup/MapPopup.tsx
Normal file
255
src/components/molecules/MapPopup/MapPopup.tsx
Normal 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;
|
||||||
2
src/components/molecules/MapPopup/index.ts
Normal file
2
src/components/molecules/MapPopup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { MapPopup, default } from './MapPopup';
|
||||||
|
export type { MapPopupProps } from './MapPopup';
|
||||||
Reference in New Issue
Block a user