import {useCallback, useState} from 'react' import {useShopActions} from '../../internal/useShopActions' import {Product} from '../../types' import {fileToDataUri} from '../../utils' /** * Maximum size of the base64-encoded image payload accepted by the backend. * Matches `MAX_ENCODED_IMAGE_SIZE_BYTES` in * `Minis::FetchSimilarProductsAction` on shop-server. */ const MAX_ENCODED_IMAGE_SIZE_BYTES = 4 * 1024 * 1024 /** * MIME types accepted by the backend for image-based similar product search. * Matches `SUPPORTED_MEDIA_CONTENT_TYPES` in `Minis::FetchSimilarProductsAction`. */ const SUPPORTED_IMAGE_TYPES: ReadonlyArray = ['image/jpeg'] const MIN_FIRST = 1 const MAX_FIRST = 10 const DEFAULT_FIRST = 10 /** * Source for the similar product search. Exactly one of `image`, * `productId`, or `productVariantId` must be provided. * * Pass a `File` (as returned by `useImagePicker`) for image-based search; * the hook handles base64 encoding and validation internally. */ export interface FindSimilarProductsParams { /** * A JPEG image file (e.g. from `useImagePicker`). Must be `image/jpeg` and * <= 4 MiB once base64-encoded. */ image?: File /** * Shopify Product GID, e.g. `gid://shopify/Product/123`. The backend resolves * the product's default variant before searching. */ productId?: string /** * Shopify ProductVariant GID, e.g. `gid://shopify/ProductVariant/456`. */ productVariantId?: string /** * Number of products to return. Must be between 1 and 10 (default 10). */ first?: number } export interface UseSimilarProductsReturns { /** * Imperative search function. Resolves to an array of similar products, or * an empty array when the backend has no matches. * * Throws if: * - The input is ambiguous (more than one of `image` / `productId` / * `productVariantId` is provided), or none are provided. * - The image MIME type is not `image/jpeg`. * - The base64-encoded image is larger than 4 MiB. * - `first` is outside the 1..10 range. */ findSimilarProducts: (params: FindSimilarProductsParams) => Promise /** * The most recent successful search result. `null` until the first * successful call. */ products: Product[] | null /** * Whether a search is currently in flight. */ loading: boolean /** * The last error thrown by `findSimilarProducts`, if any. */ error: Error | null } const stripDataUriPrefix = (dataUri: string): string => { const commaIndex = dataUri.indexOf(',') return commaIndex === -1 ? dataUri : dataUri.slice(commaIndex + 1) } const encodeImageToBase64 = async (image: File): Promise => { if (!image.type) { throw new Error('Unable to determine image type for similar product search') } if (!SUPPORTED_IMAGE_TYPES.includes(image.type)) { throw new Error( `Unsupported image type for similar product search: "${image.type}". Only ${SUPPORTED_IMAGE_TYPES.join(', ')} is supported.` ) } const dataUri = await fileToDataUri(image) const base64 = stripDataUriPrefix(dataUri) if (base64.length > MAX_ENCODED_IMAGE_SIZE_BYTES) { throw new Error( `Image is too large for similar product search: encoded size ${base64.length} bytes exceeds the ${MAX_ENCODED_IMAGE_SIZE_BYTES} byte limit (~4 MiB). Resize or compress the image before calling findSimilarProducts.` ) } return base64 } const assertExactlyOneSource = (params: FindSimilarProductsParams): void => { // Use truthiness so empty strings (e.g. from uninitialized route/query params) // count as absent. This keeps validation consistent with the request-building // branches below, which also use truthiness, and prevents malformed payloads // like `{productId: undefined}` from being sent to the backend. const provided = [ Boolean(params.image), Boolean(params.productId), Boolean(params.productVariantId), ].filter(Boolean).length if (provided === 0) { throw new Error( 'findSimilarProducts requires exactly one source: pass `image`, `productId`, or `productVariantId` (empty strings are treated as absent).' ) } if (provided > 1) { throw new Error( 'findSimilarProducts requires exactly one source: pass only one of `image`, `productId`, or `productVariantId`.' ) } } const assertValidFirst = (first: number): void => { if (!Number.isInteger(first) || first < MIN_FIRST || first > MAX_FIRST) { throw new Error( `findSimilarProducts \`first\` must be an integer between ${MIN_FIRST} and ${MAX_FIRST} (got ${first}).` ) } } export const useSimilarProducts = (): UseSimilarProductsReturns => { const {getSimilarProducts} = useShopActions() const [products, setProducts] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const findSimilarProducts = useCallback( async (params: FindSimilarProductsParams): Promise => { setLoading(true) setError(null) try { assertExactlyOneSource(params) const first = params.first ?? DEFAULT_FIRST assertValidFirst(first) let similarTo if (params.image) { similarTo = { media: { contentType: 'image/jpeg' as const, base64: await encodeImageToBase64(params.image), }, } } else if (params.productVariantId) { similarTo = {productVariantId: params.productVariantId} } else { similarTo = {productId: params.productId!} } const result = await getSimilarProducts({similarTo, first}) if (!result.ok) { throw result.error } const nextProducts = result.data.data ?? [] setProducts(nextProducts) return nextProducts } catch (rawError) { const thrown = rawError instanceof Error ? rawError : new Error(String(rawError)) setError(thrown) throw thrown } finally { setLoading(false) } }, [getSimilarProducts] ) return { findSimilarProducts, products, loading, error, } } /** * The `useSimilarProducts` hook finds products in the Shop catalog that are * visually or semantically similar to a source product, product variant, or * JPEG image. It powers "Find Similar" experiences in Minis \u2014 for example, * letting a buyer snap a photo and discover matching products from * Shop merchants. * * ### Source * * Exactly one of `image`, `productId`, or `productVariantId` must be * provided to `findSimilarProducts`: * * - **`image`** \u2014 a `File` from `useImagePicker` (`openCamera` or * `openGallery`). The hook validates that the image is `image/jpeg`, * encodes it to base64, and enforces a 4 MiB encoded size limit before * calling the backend. Partners never need to touch base64. * - **`productId`** \u2014 a Shopify Product GID. The backend resolves the * product's default variant before searching. * - **`productVariantId`** \u2014 a Shopify ProductVariant GID. * * ### Limits * * - `first` must be an integer between 1 and 10 (default 10). * - JPEG only; PNG and other formats are rejected with a clear error. * - Encoded image size cap is 4 MiB. Resize or compress the image before * calling if your photos exceed this (a future version may compress * automatically). * - Pagination cursor (`after`) is currently ignored by the backend, so * only a first page of results is returned. * * @publicDocs */ export type UseSimilarProductsGeneratedType = () => UseSimilarProductsReturns