PackageDetail: add inCart state for the Compare button

When a package is already in the comparison basket, the Compare button
swaps to a "In comparison" selected-state: soft brand-50 fill +
brand-300 border + brand-700 text + leading check icon. Technically
disabled (aria-disabled + no onClick) but sx-overrides the default
greyed Mui-disabled look so it reads as "selected/added," not
"unavailable."

Pattern: e-commerce "Added to cart" state. Removal happens via the
floating CompareBar (already owns basket mutation), not this button —
keeps the responsibility split clean.

API:
- PackageDetail: new `inCart?: boolean` prop.
- PackagesStep: forwarded as `isSelectedPackageInCart?: boolean`.
- Demo route (Packages.tsx): computes `basket.has(key)` for the
  current selection and passes it through.

Storybook: new PackageDetail story `InCart` alongside the existing
`Default` and `CompareLoading` states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:58:56 +10:00
parent 75832ced24
commit 13bd245872
4 changed files with 70 additions and 13 deletions

View File

@@ -159,6 +159,21 @@ export const CompareLoading: Story = {
},
};
/** "In comparison" selected state — package is already in the basket.
* The Compare button is swapped for a soft-brand-tinted inert pill
* with a check icon; removal happens via the CompareBar, not here. */
export const InCart: Story = {
args: {
name: 'Traditional Family Cremation Service',
price: 6966,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6966,
onArrange: () => alert('Make Arrangement'),
onCompare: () => {},
inCart: true,
},
};
// --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and optionals only — no extras */

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Box from '@mui/material/Box';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
@@ -53,6 +54,11 @@ export interface PackageDetailProps {
arrangeDisabled?: boolean;
/** Whether the compare button is in loading state */
compareLoading?: boolean;
/** Whether this package is already in the comparison basket. When true,
* the Compare button swaps to an "In comparison" selected-state (soft
* brand tint + check icon) and is inert — removal happens via the
* CompareBar, not this button. */
inCart?: boolean;
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
arrangeLabel?: string;
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
@@ -124,6 +130,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
terms,
onArrange,
onCompare,
inCart = false,
arrangeDisabled = false,
compareLoading = false,
arrangeLabel = 'Make Arrangement',
@@ -212,7 +219,31 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
>
{arrangeLabel}
</Button>
{onCompare && (
{onCompare &&
(inCart ? (
// Selected-state: soft brand tint + check icon. Inert
// (disabled + aria-disabled) — removal happens via the
// CompareBar, not this button. sx override keeps the
// brand tint instead of the default greyed-disabled look.
<Button
variant="outlined"
color="secondary"
size="large"
disabled
startIcon={<CheckRoundedIcon />}
aria-label="Already in comparison"
sx={{
flexShrink: 0,
'&.Mui-disabled': {
bgcolor: 'var(--fa-color-brand-50)',
borderColor: 'var(--fa-color-brand-300)',
color: 'var(--fa-color-text-brand)',
},
}}
>
In comparison
</Button>
) : (
<Button
variant="soft"
color="secondary"
@@ -223,7 +254,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
>
Compare
</Button>
)}
))}
</Box>
</Box>

View File

@@ -94,6 +94,10 @@ export interface PackagesStepProps {
onArrange: () => void;
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
onCompare?: () => void;
/** Whether the currently-selected package is already in the comparison
* basket. When true, PackageDetail swaps its Compare button into the
* "In comparison" selected-state (inert; removal via CompareBar). */
isSelectedPackageInCart?: boolean;
/** Callback when a nearby-verified package card is clicked (route change to that provider) */
onNearbyPackageClick?: (id: string) => void;
/**
@@ -192,6 +196,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
onSelectPackage,
onArrange,
onCompare,
isSelectedPackageInCart = false,
onNearbyPackageClick,
onSeeAllPackages,
onProviderClick,
@@ -275,6 +280,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
terms={selectedPackage.terms}
onArrange={onArrange}
onCompare={onCompare}
inCart={isSelectedPackageInCart}
arrangeDisabled={loading}
arrangeLabel={copy.arrangeLabel}
priceDisclaimer={copy.priceDisclaimer}

View File

@@ -23,11 +23,15 @@ export function PackagesRoute() {
// Compare CTA on the PackageDetail panel just adds the selection to the
// basket. The floating CompareBar (mounted in App.tsx) handles navigation
// once the user has 2+ packages selected.
// and removal once the user has 2+ packages selected.
const handleCompare = () => {
if (selectedId) basket.add(makeBasketKey(provider.id, selectedId));
};
// When the selected package is already in the basket, PackageDetail swaps
// the Compare button into its "In comparison" selected state.
const isSelectedInCart = selectedId ? basket.has(makeBasketKey(provider.id, selectedId)) : false;
// Tier-3 / tier-2 providers show "nearby verified" cards instead of
// "more from this provider".
const secondaryList =
@@ -51,6 +55,7 @@ export function PackagesRoute() {
)
}
onCompare={handleCompare}
isSelectedPackageInCart={isSelectedInCart}
onNearbyPackageClick={(key) => {
const [otherProviderId] = key.split(':');
if (otherProviderId) navigate(`/providers/${otherProviderId}/packages`);