From d2b648750fa1b83596bb71307093e0007f60b9a7 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 23 Apr 2026 13:32:06 +1000 Subject: [PATCH] PackagesStep: surface verified providers via 2-col MiniCard grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../PackagesStep/PackagesStep.stories.tsx | 51 ++++++++++------- .../pages/PackagesStep/PackagesStep.tsx | 57 ++++++++++++------- src/components/pages/PackagesStep/types.ts | 36 +++++++----- src/demo/apps/arrangement/routes/Packages.tsx | 24 +++++--- src/demo/shared/fixtures/packages.ts | 53 +++++++---------- 5 files changed, 123 insertions(+), 98 deletions(-) diff --git a/src/components/pages/PackagesStep/PackagesStep.stories.tsx b/src/components/pages/PackagesStep/PackagesStep.stories.tsx index 1f23494..20988d9 100644 --- a/src/components/pages/PackagesStep/PackagesStep.stories.tsx +++ b/src/components/pages/PackagesStep/PackagesStep.stories.tsx @@ -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} diff --git a/src/components/pages/PackagesStep/PackagesStep.tsx b/src/components/pages/PackagesStep/PackagesStep.tsx index 538d87b..7758be0 100644 --- a/src/components/pages/PackagesStep/PackagesStep.tsx +++ b/src/components/pages/PackagesStep/PackagesStep.tsx @@ -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 = { // 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 = ({ onArrange, onCompare, isSelectedPackageInCart = false, - onNearbyPackageClick, + onNearbyProviderClick, onSeeAllPackages, onProviderClick, onBack, @@ -429,29 +434,37 @@ export const PackagesStep: React.FC = ({ {/* ─── Secondary: nearby-verified ─── */} {activeSecondaryList?.kind === 'nearby-verified' && - activeSecondaryList.packages.length > 0 && ( + activeSecondaryList.providers.length > 0 && ( <> - - + + - Similar packages from verified providers nearby + Similar packages from verified providers - {activeSecondaryList.packages.map((pkg) => ( - onNearbyPackageClick(pkg.id) : undefined} + {activeSecondaryList.providers.slice(0, NEARBY_VERIFIED_LIMIT).map((p) => ( + onNearbyProviderClick(p.id) : undefined} /> ))} diff --git a/src/components/pages/PackagesStep/types.ts b/src/components/pages/PackagesStep/types.ts index b6b6a47..9944196 100644 --- a/src/components/pages/PackagesStep/types.ts +++ b/src/components/pages/PackagesStep/types.ts @@ -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[]; }; diff --git a/src/demo/apps/arrangement/routes/Packages.tsx b/src/demo/apps/arrangement/routes/Packages.tsx index a659e34..16df26a 100644 --- a/src/demo/apps/arrangement/routes/Packages.tsx +++ b/src/demo/apps/arrangement/routes/Packages.tsx @@ -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 ( 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} diff --git a/src/demo/shared/fixtures/packages.ts b/src/demo/shared/fixtures/packages.ts index 744298e..1952a7e 100644 --- a/src/demo/shared/fixtures/packages.ts +++ b/src/demo/shared/fixtures/packages.ts @@ -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, + }));