import * as React from 'react' import {useCallback, useContext, useMemo, useState} from 'react' import {type Product, type ProductVariant} from '@shopify/shop-minis-platform' import {useShopNavigation} from '../../hooks/navigation/useShopNavigation' import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions' import {ProductReviewStars} from '../../internal/components/product-review-stars' import {useProductImpression} from '../../internal/useProductImpression' import {cn} from '../../lib/utils' import {formatMoney} from '../../utils/formatMoney' import {Image} from '../atoms/image' import {ProductVariantPrice} from '../atoms/product-variant-price' import {Touchable} from '../atoms/touchable' import {Badge} from '../ui/badge' import {FavoriteButton} from './favorite-button' // Context definition interface ProductCardContextValue { // Core data product: Product selectedProductVariant?: ProductVariant // UI configuration variant: 'default' | 'priceOverlay' | 'compact' touchable: boolean badgeText?: string badgeVariant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'none' favoriteButtonDisabled: boolean reviewsDisabled: boolean // State isFavorited: boolean // Actions onClick: () => void onFavoriteToggle: () => void } const ProductCardContext = React.createContext< ProductCardContextValue | undefined >(undefined) function useProductCardContext() { const context = useContext(ProductCardContext) if (!context) { throw new Error( 'ProductCard components must be used within a ProductCard provider' ) } return context } // Primitive components (building blocks) function ProductCardContainer({ className, ...props }: React.ComponentProps<'div'>) { const {touchable, onClick} = useProductCardContext() const content = (
) if (touchable && onClick) { return ( {content} ) } return content } function ProductCardImageContainer({ className, ...props }: React.ComponentProps<'div'>) { const {variant} = useProductCardContext() return (
) } function ProductCardImage({className, ...props}: React.ComponentProps<'img'>) { const {product, selectedProductVariant} = useProductCardContext() // Derive display image locally const displayImage = selectedProductVariant?.image || product.featuredImage const src = displayImage?.url const alt = displayImage?.altText || product.title const thumbhash = product.featuredImage?.thumbhash const renderImageElement = useCallback( (src: string) => { const imageElement = thumbhash ? ( {alt} ) : ( {alt} ) return imageElement }, [alt, className, props, thumbhash] ) return (
{src ? ( renderImageElement(src) ) : (
No Image
)}
) } function ProductCardBadge({ className, position = 'bottom-left', variant, children, ...props }: React.ComponentProps & { position?: 'top-left' | 'bottom-left' }) { const {badgeText, badgeVariant} = useProductCardContext() // If no children provided, use badgeText from context const content = children || badgeText if (!content) return null return (
{content}
) } function ProductCardFavoriteButton({ className, ...props }: React.ComponentProps<'div'>) { const {isFavorited, favoriteButtonDisabled, onFavoriteToggle} = useProductCardContext() if (favoriteButtonDisabled) return null return (
) } function ProductCardInfo({className, ...props}: React.ComponentProps<'div'>) { const {variant} = useProductCardContext() if (variant !== 'default') { return null } return (
) } function ProductCardTitle({ className, children, ...props }: React.ComponentProps<'h3'>) { const {product} = useProductCardContext() return (

{children || product.title}

) } function ProductCardReviewStars({ className, ...props }: React.ComponentProps<'div'>) { const {product, reviewsDisabled} = useProductCardContext() const reviewAnalytics = product.reviewAnalytics if (reviewsDisabled || !reviewAnalytics?.averageRating) { return null } return (
) } function ProductCardPrice({className}: {className?: string}) { const {product, selectedProductVariant} = useProductCardContext() // Derive price data locally const displayPrice = selectedProductVariant?.price || product?.price const displayCompareAtPrice = selectedProductVariant?.compareAtPrice || product?.compareAtPrice return ( ) } // Special PriceOverlayBadge for price overlay variant function ProductCardPriceOverlayBadge() { const {product, selectedProductVariant, variant} = useProductCardContext() if (variant !== 'priceOverlay') return null const displayPrice = selectedProductVariant?.price || product.price const currencyCode = displayPrice?.currencyCode const amount = displayPrice?.amount if (!currencyCode || !amount) return null return ( {formatMoney(amount, currencyCode)} ) } /** * Optimized for grid layouts with three variants: `default`, `priceOverlay`, and `compact`. Use default for product grids where details matter, priceOverlay for visual-first feeds, compact when dealing with limited space. For some list views where space is limited, `ProductLink` may work better. Exposes composable subcomponents for custom layouts. * @publicDocs */ export interface ProductCardProps { /** The product to display in the card */ product: Product /** Optional selected variant of the product to show specific variant data */ selectedProductVariant?: ProductVariant /** Visual style variant of the card */ variant?: 'default' | 'priceOverlay' | 'compact' /** Whether the card can be clicked/tapped to navigate to product details */ touchable?: boolean /** Optional text to display in a badge on the card */ badgeText?: string /** Visual style variant for the badge */ badgeVariant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'none' /** Callback fired when the product is clicked */ onProductClick?: () => void /** Callback fired when the favorite button is toggled */ onFavoriteToggled?: (isFavorited: boolean) => void /** Custom layout via children */ children?: React.ReactNode /** Whether the favorite button is disabled */ favoriteButtonDisabled?: boolean /** Whether review stars are disabled */ reviewsDisabled?: boolean /** Whether to disable impression tracking */ impressionTrackingDisabled?: boolean } function ProductCard({ product, selectedProductVariant, variant = 'default', touchable = true, badgeText, badgeVariant, onProductClick, onFavoriteToggled, children, favoriteButtonDisabled = false, reviewsDisabled = false, impressionTrackingDisabled = false, }: ProductCardProps) { const {navigateToProduct} = useShopNavigation() const {saveProduct, unsaveProduct} = useSavedProductsActions() const {ref: impressionRef} = useProductImpression({ productId: product.id, skip: impressionTrackingDisabled, }) // Local state for optimistic UI updates const [isFavoritedLocal, setIsFavoritedLocal] = useState(product.isFavorited) const handleClick = useCallback(() => { if (!touchable) return onProductClick?.() navigateToProduct({ productId: product.id, }) }, [navigateToProduct, product.id, touchable, onProductClick]) const handleFavoriteClick = useCallback(async () => { const previousState = isFavoritedLocal // Optimistic update setIsFavoritedLocal(!previousState) onFavoriteToggled?.(!previousState) try { if (previousState) { await unsaveProduct({ productId: product.id, shopId: product.shop.id, productVariantId: selectedProductVariant?.id || product.defaultVariantId, }) } else { await saveProduct({ productId: product.id, shopId: product.shop.id, productVariantId: selectedProductVariant?.id || product.defaultVariantId, }) } } catch (error) { // Revert optimistic update on error setIsFavoritedLocal(previousState) onFavoriteToggled?.(previousState) } }, [ isFavoritedLocal, product.id, product.shop.id, product.defaultVariantId, selectedProductVariant?.id, saveProduct, unsaveProduct, onFavoriteToggled, ]) const contextValue = useMemo( () => ({ // Core data product, selectedProductVariant, // UI configuration variant, touchable, badgeText, badgeVariant, favoriteButtonDisabled, reviewsDisabled, // State isFavorited: isFavoritedLocal, // Actions onClick: handleClick, onFavoriteToggle: handleFavoriteClick, }), [ product, selectedProductVariant, variant, touchable, badgeText, badgeVariant, isFavoritedLocal, handleClick, handleFavoriteClick, favoriteButtonDisabled, reviewsDisabled, ] ) return (
{children ?? ( {variant === 'priceOverlay' && } {variant === 'default' && ( )} )}
) } export { ProductCard, ProductCardContainer, ProductCardImageContainer, ProductCardImage, ProductCardBadge, ProductCardFavoriteButton, ProductCardInfo, ProductCardTitle, ProductCardReviewStars, ProductCardPrice, }