PackagesStep: surface verified providers via 2-col MiniCard grid

The unverified-tier "similar packages" section previously rendered a list
of NearbyPackageCards — one per package. Swap to MiniCard, showing the
provider itself: image, verified badge, location, rating, "From $X".
2-col on sm+, 1-col on xs, capped at 4. Heading dropped "nearby" to
"Similar packages from verified providers".

Data shape renamed NearbyVerifiedPackage → NearbyVerifiedProvider;
`verified` is implicit (the section is verified-only by definition).
Callback renamed onNearbyPackageClick → onNearbyProviderClick, routing
directly on provider id. Demo fixture now derives the list from the
main providers fixture (filtered to verified + imageUrl).

NearbyPackageCard is now orphaned — kept in place pending registry
cleanup in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 13:32:06 +10:00
parent 4a0fcd0294
commit d2b648750f
5 changed files with 123 additions and 98 deletions

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { PackagesStep } from './PackagesStep';
import type { NearbyVerifiedPackage, PackageData, PackagesStepProvider } from './PackagesStep';
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep';
import { Navigation } from '../../organisms/Navigation';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -211,34 +211,43 @@ const manyOtherPackages: PackageData[] = [
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
const nearbyVerifiedProviders: NearbyVerifiedProvider[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
id: 'rankins',
name: 'Rankins Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Warrawong, NSW',
startingPrice: 2450,
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
id: 'mannings',
name: 'Mannings Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Bega, NSW',
startingPrice: 1950,
rating: 4.7,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
id: 'killick',
name: 'Killick Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Kingaroy, QLD',
startingPrice: 3100,
rating: 4.9,
reviewCount: 15,
},
{
id: 'mackay',
name: 'Mackay Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Ourimbah, NSW',
startingPrice: 2780,
rating: 4.6,
reviewCount: 19,
},
];
const tier2Packages: PackageData[] = [
@@ -362,12 +371,12 @@ export const Tier3: Story = {
provider={unverifiedProvider}
providerTier="tier3"
packages={matchedPackages}
secondaryList={{ kind: 'nearby-verified', packages: nearbyVerifiedPackages }}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${id}`)}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
@@ -388,12 +397,12 @@ export const Tier2: Story = {
provider={unverifiedProvider}
providerTier="tier2"
packages={tier2Packages}
secondaryList={{ kind: 'nearby-verified', packages: nearbyVerifiedPackages }}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${id}`)}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}

View File

@@ -8,7 +8,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { NearbyPackageCard } from '../../molecules/NearbyPackageCard';
import { MiniCard } from '../../molecules/MiniCard';
import { PackageDetail } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
@@ -18,7 +18,7 @@ import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } f
export type {
PackageData,
PackagesStepProvider,
NearbyVerifiedPackage,
NearbyVerifiedProvider,
ProviderTier,
SecondaryList,
} from './types';
@@ -75,6 +75,10 @@ const TIER_COPY: Record<ProviderTier, TierCopy> = {
// switching to "top N + See all →" behaviour.
const SAME_PROVIDER_INLINE_LIMIT = 3;
// Max number of verified provider MiniCards in the "Similar packages from
// verified providers" grid on unverified pages.
const NEARBY_VERIFIED_LIMIT = 4;
// ─── Props ───────────────────────────────────────────────────────────────────
export interface PackagesStepProps {
@@ -98,8 +102,8 @@ export interface PackagesStepProps {
* 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;
/** Callback when a nearby-verified provider card is clicked (route change to that provider's PackagesStep) */
onNearbyProviderClick?: (id: string) => void;
/**
* Callback when "See all N packages from [Provider]" is clicked.
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
@@ -177,7 +181,8 @@ function GroupHeading({
* shows top 3 + "See all N packages from [Provider] →" link that routes
* to the same page with `showAllFromProvider`.
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
* from verified providers nearby" list (NearbyPackageCard).
* from verified providers" 2-column MiniCard grid, capped at 4. Every
* card is verified by definition.
*
* When `showAllFromProvider` is true, renders a flat "All packages from
* [Provider]" list with no grouping and no secondary list. The caller
@@ -197,7 +202,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
onArrange,
onCompare,
isSelectedPackageInCart = false,
onNearbyPackageClick,
onNearbyProviderClick,
onSeeAllPackages,
onProviderClick,
onBack,
@@ -429,29 +434,37 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
{/* ─── Secondary: nearby-verified ─── */}
{activeSecondaryList?.kind === 'nearby-verified' &&
activeSecondaryList.packages.length > 0 && (
activeSecondaryList.providers.length > 0 && (
<>
<Divider sx={{ my: 8 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'primary.main', mt: '3px' }}
aria-hidden
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
Similar packages from verified providers
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
aria-label="Similar packages from verified providers"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 2,
mb: 3,
}}
>
{activeSecondaryList.packages.map((pkg) => (
<NearbyPackageCard
key={pkg.id}
packageName={pkg.packageName}
price={pkg.price}
providerName={pkg.providerName}
location={pkg.location}
rating={pkg.rating}
reviewCount={pkg.reviewCount}
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
{activeSecondaryList.providers.slice(0, NEARBY_VERIFIED_LIMIT).map((p) => (
<MiniCard
key={p.id}
title={p.name}
imageUrl={p.imageUrl}
verified
price={p.startingPrice}
location={p.location}
rating={p.rating}
onClick={onNearbyProviderClick ? () => onNearbyProviderClick(p.id) : undefined}
/>
))}
</Box>

View File

@@ -55,19 +55,26 @@ export interface PackageData {
terms?: string;
}
/** A package offered by a nearby verified provider (promoted on unverified pages). */
export interface NearbyVerifiedPackage {
/** Unique ID */
/**
* A verified provider surfaced on an unverified provider's PackagesStep.
*
* By definition every entry in this list is verified — the section is a
* curated "here are the real partners near you" promotion — so there is no
* `verified` flag on the data shape. Components that render this list pass
* a hard-coded `verified={true}` to their card.
*/
export interface NearbyVerifiedProvider {
/** Provider ID — routes to `/providers/:id/packages` */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
name: string;
/** Hero image URL (verified providers always have one) */
imageUrl: string;
/** Location (suburb, state) */
location: string;
/** Provider rating */
/** Starting price — formatted as "From $X" on the card */
startingPrice: number;
/** Average rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
@@ -82,9 +89,10 @@ export interface NearbyVerifiedPackage {
* Rendered as a ServiceOption list. If more than 3, the list shows the
* first 3 + a "See all N packages from [Provider]" link that navigates
* to the same PackagesStep with preference filters off.
* - `nearby-verified`: Similar packages from nearby verified providers,
* promoted on unverified-tier pages. Rendered as NearbyPackageCard list.
* Clicking a card is a route change to that provider's PackagesStep.
* - `nearby-verified`: Verified providers promoted on unverified-tier pages
* under the heading "Similar packages from verified providers". Rendered
* as a 2-col MiniCard grid capped at 4. Clicking a card routes to that
* provider's PackagesStep.
*/
export type SecondaryList =
| {
@@ -93,5 +101,5 @@ export type SecondaryList =
}
| {
kind: 'nearby-verified';
packages: NearbyVerifiedPackage[];
providers: NearbyVerifiedProvider[];
};

View File

@@ -5,7 +5,7 @@ import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/
import {
packagesByProvider,
makeBasketKey,
nearbyVerifiedSamples,
nearbyVerifiedProviders,
} from '../../../shared/fixtures/packages';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { demoNav } from '../DemoNav';
@@ -34,19 +34,28 @@ export function PackagesRoute() {
// 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".
// Tier-3 / tier-2 providers show verified-provider MiniCards instead of
// "more from this provider". Exclude the current provider from the
// "similar" list in case we ever add a verified id that collides.
const secondaryList =
provider.tier === 'verified'
? { kind: 'same-provider-more' as const, packages: bundle.other }
: { kind: 'nearby-verified' as const, packages: nearbyVerifiedSamples };
: {
kind: 'nearby-verified' as const,
providers: nearbyVerifiedProviders.filter((p) => p.id !== provider.id),
};
const secondaryHasItems =
secondaryList.kind === 'same-provider-more'
? secondaryList.packages.length > 0
: secondaryList.providers.length > 0;
return (
<PackagesStep
provider={toPackagesStepProvider(provider)}
providerTier={provider.tier}
packages={bundle.matching}
secondaryList={secondaryList.packages.length > 0 ? secondaryList : undefined}
secondaryList={secondaryHasItems ? secondaryList : undefined}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() =>
@@ -58,10 +67,7 @@ export function PackagesRoute() {
}
onCompare={handleCompare}
isSelectedPackageInCart={isSelectedInCart}
onNearbyPackageClick={(key) => {
const [otherProviderId] = key.split(':');
if (otherProviderId) navigate(`/providers/${otherProviderId}/packages`);
}}
onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)}
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
onBack={() => navigate('/')}
navigation={demoNav}

View File

@@ -1,10 +1,10 @@
import type { PackageData, NearbyVerifiedPackage } from '../../../components/pages/PackagesStep';
import type { PackageData, NearbyVerifiedProvider } from '../../../components/pages/PackagesStep';
import type { PackageSection, PackageLineItem } from '../../../components/organisms/PackageDetail';
import type {
ComparisonPackage,
ComparisonSection,
} from '../../../components/organisms/ComparisonTable';
import { providersById } from './providers';
import { providers, providersById } from './providers';
/**
* Packages live keyed by providerId. Each provider has TWO PackageData lists
@@ -876,33 +876,22 @@ export function resolveComparisonPackage(key: BasketKey): ComparisonPackage | nu
return bundle.forComparison.find((p) => p.id === parsed.packageId) ?? null;
}
/** "Nearby verified" cards shown under tier-3 / tier-2 lists. */
export const nearbyVerifiedSamples: NearbyVerifiedPackage[] = [
{
id: 'rankins:standard',
packageName: 'Standard Cremation Package',
price: rankinsForStep[0].price,
providerName: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'mannings:standard',
packageName: 'Standard Cremation Package',
price: manningsForStep[0].price,
providerName: 'Mannings Funerals',
location: 'Bega, NSW',
rating: 4.7,
reviewCount: 31,
},
{
id: 'killick:classic',
packageName: 'Classic Farewell Package',
price: killickForStep[0].price,
providerName: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
rating: 4.9,
reviewCount: 15,
},
];
/**
* Verified providers surfaced under the "Similar packages from verified
* providers" grid on unverified tier-2 / tier-3 pages. Derived from the
* main `providers` fixture filtered to tier === 'verified', with
* `startingPrice` taken from their first matching package. Only providers
* that actually have an image in the fixture are eligible (MiniCard
* requires `imageUrl`).
*/
export const nearbyVerifiedProviders: NearbyVerifiedProvider[] = providers
.filter((p) => p.tier === 'verified' && p.imageUrl)
.map((p) => ({
id: p.id,
name: p.name,
imageUrl: p.imageUrl!,
location: p.location,
startingPrice: packagesByProvider[p.id]?.matching[0]?.price ?? p.startingPrice ?? 0,
rating: p.rating,
reviewCount: p.reviewCount,
}));