Skip to content

[cdx-436]: add-product-swatches-for-product-card - #52

Merged
niizom merged 2 commits into
mainfrom
cdx-436-add-product-swatches-for-product-card
Aug 27, 2026
Merged

[cdx-436]: add-product-swatches-for-product-card#52
niizom merged 2 commits into
mainfrom
cdx-436-add-product-swatches-for-product-card

Conversation

@niizom

@niizom niizom commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Pull Request Checklist

Before you submit a pull request, please make sure you have to following:

  • I have added or updated TypeScript types for my changes, ensuring they are compatible with the existing codebase.
  • I have added JSDoc comments to my TypeScript definitions for improved documentation.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added any necessary documentation (if appropriate).
  • I have made sure my PR is up-to-date with the main branch.

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no API changes)
  • Documentation content changes
  • TypeScript type definitions update
  • Other... Please describe:

Copilot AI review requested due to automatic review settings July 10, 2026 12:38
@niizom
niizom requested a review from a team as a code owner July 10, 2026 12:38
@niizom
niizom force-pushed the cdx-436-add-product-swatches-for-product-card branch from 4950df9 to 514431a Compare July 10, 2026 12:39
@niizom niizom changed the title [cdx-436-add-product-swatches-for-product-card] [cdx-436]: add-product-swatches-for-product-card Jul 10, 2026
constructor-claude-bedrock[bot]

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds product swatches (color/variant selectors) to the ProductCard component, including optional “View more” truncation/expansion behavior, with Storybook docs/examples and test coverage.

Changes:

  • Introduces swatch-related types and a new useProductSwatch hook to manage selection + truncation/expansion state.
  • Updates ProductCard to render a SwatchSection, apply selected-swatch overrides to displayed product data, and emit events/callbacks using the displayed variant.
  • Adds Storybook stories/docs and unit/component tests for swatch behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/utils/styleHelpers.ts Adds isHexColor helper used to decide hex-vs-image swatch previews.
src/types/productCardTypes.ts Adds swatch types and new ProductCard props/override hooks for swatches.
src/hooks/useProductSwatch.ts New hook for swatch selection and “view more” expansion logic.
src/components/product-card.tsx Renders swatches, computes displayProduct, and wires “view more” behavior + overrides.
src/stories/components/ProductCard/UsagePatterns.mdx Documents new swatch usage patterns in Storybook.
src/stories/components/ProductCard/ProductCard.stories.tsx Adds swatch-focused stories (basic, image swatches, view-more).
src/stories/components/ProductCard/Code Examples - Swatches.mdx New swatch code example documentation page.
src/stories/components/ProductCard/Code Examples - Compound Components.mdx Documents new ProductCard.SwatchSection compound component.
spec/hooks/useProductSwatch.test.ts Adds unit tests for hook selection/truncation/expansion.
spec/components/product-card/product-card.test.tsx Adds component tests validating UI, data overrides, and view-more behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +23
import { useMemo, useState, useCallback } from 'react';
import type { Product, SwatchItem, ProductSwatchObject } from '@/types/productCardTypes';

export function useProductSwatch(
{ swatchList = [], variationId }: Product,
maxSwatches?: number,
): ProductSwatchObject {
const [selectedSwatch, setSelectedSwatch] = useState<SwatchItem | undefined>(() =>
swatchList.find((swatch) => swatch.variationId === variationId),
);
const [isExpanded, setIsExpanded] = useState(false);

const onSwatchClick = useCallback((swatch: SwatchItem) => {
setSelectedSwatch((selectedSwatch) => {
if (selectedSwatch?.variationId === swatch.variationId) return undefined;
else return swatch;
});
}, []);

const onViewMoreSwatchesClick = useCallback(() => {
setIsExpanded(true);
}, []);

description?: ComponentOverrideProps<ProductCardProps>;
rating?: ComponentOverrideProps<ProductCardProps>;
price?: ComponentOverrideProps<ProductCardProps>;
swatches?: ComponentOverrideProps<ProductCardProps>;
className?: string;
}

export interface SwatchSectionProps extends IncludeRenderProps<ProductCardProps> {

@Alexey-Pavlov Alexey-Pavlov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@niizom Thanks for working on this! Left a few comments there and there. Could you please check when you get a chance?

Comment thread src/components/product-card.tsx Outdated
Comment thread src/components/product-card.tsx Outdated
Comment thread src/components/product-card.tsx Outdated
const isSelected = swatchItem.variationId === selectedSwatch?.variationId;
const bgValue = isHexColor(swatchItem.swatchPreview)
? swatchItem.swatchPreview
: `url(${swatchItem.swatchPreview}) center/cover`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The raw url(${swatchPreview}) broke the background shorthand when the URL contained spaces or parens. Quote-wrapping should fix that — inside a CSS string, spaces/parens are already literal — so we only need to escape the two chars that could break out of the quotes (" and \). Does this look right?

Suggested change
: `url(${swatchItem.swatchPreview}) center/cover`;
: `url("${swatchItem.swatchPreview.replace(/["\\]/g, '\\$&')}") center/cover`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread src/components/product-card.tsx Outdated
data-testid={`cio-swatch-${swatchItem.variationId}`}
data-cnstrc-item-variation-id={swatchItem.variationId}
className={cn(
'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer p-0',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer p-0',
'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer cio:p-0',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread src/components/product-card.tsx Outdated
type='button'
data-testid='cio-swatch-show-more'
className='cio-swatch-show-more cio:bg-transparent cio:border-0 cio:p-0 cio:text-xs cio:underline cio:cursor-pointer cio:text-[var(--cio-swatch-more-color,#333)] cio:hover:text-[var(--cio-swatch-more-hover-color,#000)]'
onClick={(e) => onViewMoreSwatchesClick?.(e, selectedSwatch)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
onClick={(e) => onViewMoreSwatchesClick?.(e, selectedSwatch)}
onClick={(e) => {
e.stopPropagation();
onViewMoreSwatchesClick?.(e, selectedSwatch);
}}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread src/utils/styleHelpers.ts
Comment thread src/hooks/useProductSwatch.ts
constructor-claude-bedrock[bot]

This comment was marked as outdated.

@Alexey-Pavlov Alexey-Pavlov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! Thanks for working on this!

@niizom
niizom force-pushed the cdx-436-add-product-swatches-for-product-card branch from 6e74376 to f96c97f Compare August 27, 2026 17:30

@constructor-claude-bedrock constructor-claude-bedrock Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This PR adds color/variant swatch selection to ProductCard with a clean hook-based design, compound component support, and good test coverage — overall a solid implementation with a few issues to address.

Inline comments: 7 discussions added

Overall Assessment: ⚠️ Needs Work

);
const [isExpanded, setIsExpanded] = useState(false);

const productKey = JSON.stringify([id, variationId ?? null]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: Using JSON.stringify to derive a product identity key is fragile and has a hidden cost. If id is ever undefined (e.g. a product object constructed without one), the key becomes "[null,null]" and every product change will still be detected, but two truly different products with the same id/variationId pair will be treated as the same. More importantly, calling JSON.stringify on every render and then triggering three set* calls inside the render body (lines 16–18) is a pattern that requires care — React's rules allow it only when the previous state is set synchronously and the sets are guarded by a condition (the "derived state from props" pattern), but it forces an extra render cycle on every product change. A useEffect with [id, variationId] deps is the idiomatic approach here and avoids the extra render:

useEffect(() => {
  setSelectedSwatch(swatchList.find((s) => s.variationId === variationId));
  setIsExpanded(false);
}, [id, variationId]); // swatchList intentionally omitted — reset only on product identity change

props.showViewMoreSwatches ? props.maxSwatches : undefined,
);
const displayProduct = useMemo(() => {
const filtered = Object.fromEntries(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: The merge logic that builds displayProduct is lossy for falsy-but-valid values. For example, if a swatch explicitly sets price: 0 or salePrice: undefined to clear a sale price, those would be filtered out by v !== undefined, and then the base product's salePrice would remain visible. This silently produces incorrect pricing data. The filter should only remove undefined (which it does), but crucially the entire swatch.selectedSwatch object (including fields that are undefined) is spread after filtering — meaning a swatch that intentionally clears salePrice by leaving it undefined on SwatchItem will not override the base product's salePrice. Consider using a whitelist of known overridable fields and spreading only those:

const SWATCH_OVERRIDE_FIELDS = ['name','imageUrl','price','salePrice','badge','rating','reviewsCount','tags','description'] as const;
const displayProduct = useMemo(() => {
  if (!swatch.selectedSwatch) return props.product;
  const overrides = Object.fromEntries(
    SWATCH_OVERRIDE_FIELDS
      .filter((k) => swatch.selectedSwatch![k] !== undefined)
      .map((k) => [k, swatch.selectedSwatch![k]])
  );
  return { ...props.product, ...overrides };
}, [props.product, swatch.selectedSwatch]);

This also avoids accidentally copying internal swatch-only fields (like swatchPreview) onto displayProduct.

onViewMoreSwatchesClick?.(e, selected);
},
[product, onProductClick],
[expandInline, swatch.onViewMoreSwatchesClick, onViewMoreSwatchesClick],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: swatch.onViewMoreSwatchesClick is already a stable useCallback(() => { setIsExpanded(true); }, []) — but swatch itself is a new object on every render (see the context memo issue above), so swatch.onViewMoreSwatchesClick as a dep array entry is unnecessarily volatile. This will cause handleViewMoreSwatchesClick to be recreated on every render. Destructure the stable callback before the useCallback:

const { onViewMoreSwatchesClick: expandSwatches } = swatch;
const handleViewMoreSwatchesClick = useCallback(
  (e: React.MouseEvent, selected: SwatchItem | undefined) => {
    if (expandInline) expandSwatches();
    onViewMoreSwatchesClick?.(e, selected);
  },
  [expandInline, expandSwatches, onViewMoreSwatchesClick],
);

'cio:outline-3 cio:outline-offset-[4px] cio:outline-current cio:opacity-60',
)}
style={{ background: bgValue }}
onClick={(e) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The inline arrow function onClick={(e) => { e.stopPropagation(); onSwatchClick?.(e, swatchItem); }} is recreated for every swatch on every render. Since swatchList.map already recreates the JSX, this is acceptable, but for consistency with the rest of the component's useCallback pattern, consider extracting this into a small event handler — or at minimum note that this is intentional.


export interface SwatchItem {
variationId: string;
swatchPreview: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: swatchPreview is marked as required on SwatchItem, but it is the only required field besides variationId. The test at spec/components/product-card/product-card.test.tsx already demonstrates a swatch without imageUrl/price etc., but the UI code in SwatchSection will call swatchItem.swatchPreview.replace(...) (line ~408 in product-card.tsx) without a null-guard. If a consumer passes a swatch without swatchPreview (e.g. via a cast or a data source that does not guarantee the field), this will throw at runtime. Add a guard:

const bgValue = swatchItem.swatchPreview
  ? isHexColor(swatchItem.swatchPreview)
    ? swatchItem.swatchPreview
    : `url("${swatchItem.swatchPreview.replace(/["\\]/g, '\\$&')}") center/cover`
  : 'transparent';


{/* Footer Section */}
{(onAddToCart || product.tags) && (
{(onAddToCart || displayProduct.tags) && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The footer visibility condition uses displayProduct.tags (which could now change with swatch selection), but this was product.tags before this PR. This is the correct behavior — swatch-level tags should show/hide the footer — but it is worth verifying that the existing tests for the footer cover the case where the base product has no onAddToCart and no tags, but a selected swatch adds tags. If not, a test for this scenario would prevent regressions.

@niizom
niizom merged commit 8db1865 into main Aug 27, 2026
13 checks passed
@niizom
niizom deleted the cdx-436-add-product-swatches-for-product-card branch August 27, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants