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 ? (
) : (
)
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,
}