Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/template-retail-react-app/PRODUCT_COMPARISON.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Product Comparison Feature

This document describes the product comparison functionality added to the Retail React App.

## Overview

The product comparison feature allows users to:
- Add up to 4 products to a comparison list
- View compared products in a side drawer
- Navigate to a detailed comparison page
- Compare product attributes side by side
- Persist comparison data across browser sessions

## Components

### Core Components

1. **ComparisonProvider** (`app/contexts/comparison-provider.jsx`)
- Manages global comparison state
- Handles localStorage persistence
- Provides comparison context to the app

2. **useComparison Hook** (`app/hooks/use-comparison.js`)
- Convenient hook for accessing comparison functionality
- Provides methods for adding/removing products
- Manages drawer state

3. **CompareButton** (`app/components/compare-button/index.jsx`)
- Reusable button component for adding/removing products from comparison
- Available in icon and button variants
- Provides user feedback via toasts

4. **ComparisonDrawer** (`app/components/comparison-drawer/index.jsx`)
- Side drawer showing currently compared products
- Quick access to remove products
- Navigate to full comparison page

5. **ComparisonBadge** (`app/components/comparison-badge/index.jsx`)
- Floating badge showing comparison count
- Quick access to open comparison drawer

6. **ProductComparison Page** (`app/pages/product-comparison/index.jsx`)
- Full comparison page with detailed product table
- Responsive design (table on desktop, cards on mobile)
- Compare product attributes side by side

## Usage

### Enabling Comparison on Product Tiles

```jsx
<ProductTile
product={product}
enableComparison={true}
// ... other props
/>
```

### Using the Comparison Hook

```jsx
import {useComparison} from '@salesforce/retail-react-app/app/hooks'

function MyComponent() {
const {
comparedProducts,
addToComparison,
removeFromComparison,
isInComparison,
openDrawer
} = useComparison()

// Component logic...
}
```

### Adding Compare Button

```jsx
import CompareButton from '@salesforce/retail-react-app/app/components/compare-button'

<CompareButton
product={product}
variant="button" // or "icon"
size="md"
/>
```

## Features

### State Management
- Global state managed by ComparisonProvider
- Automatic persistence to localStorage
- Maximum of 4 products can be compared
- Duplicate prevention

### User Experience
- Toast notifications for user feedback
- Floating comparison badge for quick access
- Side drawer for quick product management
- Responsive comparison page

### Accessibility
- Proper ARIA labels
- Keyboard navigation support
- Screen reader friendly

## API

### ComparisonProvider Props
- `children`: React children to wrap

### useComparison Return Value
- `comparedProducts`: Array of products being compared
- `isDrawerOpen`: Boolean indicating drawer state
- `addToComparison(product)`: Add product to comparison
- `removeFromComparison(productId)`: Remove product from comparison
- `clearComparison()`: Clear all compared products
- `isInComparison(productId)`: Check if product is being compared
- `toggleDrawer()`: Toggle drawer open/closed
- `openDrawer()`: Open comparison drawer
- `closeDrawer()`: Close comparison drawer
- `canCompare`: Boolean indicating if more products can be added
- `hasProducts`: Boolean indicating if there are products to compare
- `count`: Number of products currently being compared

### CompareButton Props
- `product`: Product object (required)
- `variant`: "icon" | "button" (default: "icon")
- `size`: "sm" | "md" | "lg" (default: "md")

## Testing

Unit tests are provided for:
- useComparison hook functionality
- CompareButton component behavior
- State management and persistence

Run tests with:
```bash
npm test -- --testPathPattern=comparison
```

## Routing

The comparison page is available at `/compare` and will redirect to home if no products are selected for comparison.

## Localization

All user-facing text is internationalized using react-intl. Add translations for:
- `comparison_drawer.*`
- `compare_button.*`
- `product_comparison.*`
- `comparison_badge.*`

## Browser Support

The feature uses localStorage for persistence and falls back gracefully if not available. Supports all modern browsers that support the base PWA Kit requirements.
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ import {
import {SkipNavLink, SkipNavContent} from '@chakra-ui/skip-nav'

// Contexts
import {CurrencyProvider} from '@salesforce/retail-react-app/app/contexts'
import {CurrencyProvider, ComparisonProvider} from '@salesforce/retail-react-app/app/contexts'

// Local Project Components
import Header from '@salesforce/retail-react-app/app/components/header'
import OfflineBanner from '@salesforce/retail-react-app/app/components/offline-banner'
import OfflineBoundary from '@salesforce/retail-react-app/app/components/offline-boundary'
import ComparisonDrawer from '@salesforce/retail-react-app/app/components/comparison-drawer'
import ComparisonBadge from '@salesforce/retail-react-app/app/components/comparison-badge'
import ScrollToTop from '@salesforce/retail-react-app/app/components/scroll-to-top'
import Footer from '@salesforce/retail-react-app/app/components/footer'
import CheckoutHeader from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-header'
Expand Down Expand Up @@ -325,7 +327,8 @@ const App = (props) => {
defaultLocale={DEFAULT_LOCALE}
>
<CurrencyProvider currency={currency}>
<Seo>
<ComparisonProvider>
<Seo>
<meta name="theme-color" content={THEME_COLOR} />
<meta name="apple-mobile-web-app-title" content={DEFAULT_SITE_TITLE} />
<link
Expand Down Expand Up @@ -460,9 +463,12 @@ const App = (props) => {

<AuthModal {...authModal} />
<DntNotification {...dntNotification} />
<ComparisonDrawer />
<ComparisonBadge />
</BonusProductSelectionModalProvider>
</AddToCartModalProvider>
</Box>
</ComparisonProvider>
</CurrencyProvider>
</IntlProvider>
</StorefrontPreview>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2024, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import React from 'react'
import PropTypes from 'prop-types'
import {useIntl} from 'react-intl'
import {
IconButton,
Button,
Tooltip,
useMultiStyleConfig
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {useComparison} from '@salesforce/retail-react-app/app/hooks'
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'

// Icons - we'll create a simple compare icon using existing UI components
const CompareIcon = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 3H7c-1.1 0-2 .9-2 2v9h2V5h2V3zm4 6V7l-3 3 3 3v-2h4v2l3-3-3-3v2h-4zm2 8h2v-7h2v7c0 1.1-.9 2-2 2h-2v-2z"/>
</svg>
)

/**
* CompareButton component allows users to add/remove products from comparison.
* Can be rendered as an icon button or regular button.
*/
const CompareButton = ({
product,
variant = 'icon',
size = 'md',
...rest
}) => {
const intl = useIntl()
const toast = useToast()
const {
addToComparison,
removeFromComparison,
isInComparison,
canCompare
} = useComparison()

const isComparing = isInComparison(product.productId)
const styles = useMultiStyleConfig('CompareButton', {variant, size})

// ProductTile is used by two components, RecommendedProducts and ProductList.
// RecommendedProducts provides a localized product name as `name` and non-localized product
// name as `productName`. ProductList provides a localized name as `productName` and does not
// use the `name` property.
const localizedProductName = product.name ?? product.productName

const handleClick = async (e) => {
e.preventDefault()
e.stopPropagation()

try {
if (isComparing) {
removeFromComparison(product.productId)
toast({
title: intl.formatMessage(
{
id: 'compare_button.removed_from_comparison',
defaultMessage: '{product} removed from comparison'
},
{product: localizedProductName}
),
status: 'info',
duration: 2000
})
} else {
if (!canCompare) {
toast({
title: intl.formatMessage({
id: 'compare_button.max_products_reached',
defaultMessage: 'Maximum 4 products can be compared at once'
}),
status: 'warning',
duration: 3000
})
return
}

addToComparison(product)
toast({
title: intl.formatMessage(
{
id: 'compare_button.added_to_comparison',
defaultMessage: '{product} added to comparison'
},
{product: localizedProductName}
),
status: 'success',
duration: 2000
})
}
} catch (error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catch is not needed

toast({
title: intl.formatMessage({
id: 'compare_button.error',
defaultMessage: 'Unable to update comparison'
}),
description: error.message,
status: 'error',
duration: 3000
})
}
}

const ariaLabel = isComparing
? intl.formatMessage(
{
id: 'compare_button.remove_from_comparison',
defaultMessage: 'Remove {product} from comparison'
},
{product: localizedProductName}
)
: intl.formatMessage(
{
id: 'compare_button.add_to_comparison',
defaultMessage: 'Add {product} to comparison'
},
{product: localizedProductName}
)

const buttonText = isComparing
? intl.formatMessage({
id: 'compare_button.comparing',
defaultMessage: 'Comparing'
})
: intl.formatMessage({
id: 'compare_button.compare',
defaultMessage: 'Compare'
})

if (variant === 'icon') {
return (
<Tooltip
label={ariaLabel}
hasArrow
placement="top"
>
<IconButton
aria-label={ariaLabel}
icon={<CompareIcon />}
onClick={handleClick}
colorScheme={isComparing ? 'blue' : 'gray'}
variant={isComparing ? 'solid' : 'ghost'}
size={size}
{...styles.container}
{...rest}
/>
</Tooltip>
)
}

return (
<Button
leftIcon={<CompareIcon />}
onClick={handleClick}
colorScheme={isComparing ? 'blue' : 'gray'}
variant={isComparing ? 'solid' : 'outline'}
size={size}
{...styles.container}
{...rest}
>
{buttonText}
</Button>
)
}

CompareButton.propTypes = {
product: PropTypes.shape({
productId: PropTypes.string.isRequired,
name: PropTypes.string,
productName: PropTypes.string
}).isRequired,
variant: PropTypes.oneOf(['icon', 'button']),
size: PropTypes.oneOf(['sm', 'md', 'lg'])
}

export default CompareButton
Loading