UNPKG

@shopify/shop-minis-react

Version:

React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)

618 lines (592 loc) 18.4 kB
import { Product, ProductReview, Gender, UserState, MinisContentStatus, } from '@shopify/shop-minis-platform' import {ShopActions} from '@shopify/shop-minis-platform/actions' const SAMPLE_IMAGE_NAMES = [ 'garnished.jpeg', 'bath.jpeg', 'teapot.jpg', 'shoes.jpeg', ] // Simple hash function to get a deterministic index from a string const hashString = (str: string): number => { let hash = 0 for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i) hash |= 0 } return Math.abs(hash) } // Helper functions for common data structures export const createProduct = ( id: string, title: string, price = '99.99', compareAtPrice?: string ): Product => { const imageIndex = hashString(id) % SAMPLE_IMAGE_NAMES.length const imageName = SAMPLE_IMAGE_NAMES[imageIndex] return { id, title, price: {amount: price, currencyCode: 'USD'}, ...(compareAtPrice && { compareAtPrice: {amount: compareAtPrice, currencyCode: 'USD'}, }), reviewAnalytics: {averageRating: 4.5, reviewCount: 10}, shop: createShop('shop1', 'Mock Shop'), defaultVariantId: `variant-${id}`, isFavorited: false, featuredImage: { url: `https://cdn.shopify.com/static/sample-images/${imageName}`, altText: title, }, } } export const createProductReview = ( id: string, overrides: Partial<ProductReview> = {} ): ProductReview => { return { id, rating: 5, title: 'Great product', body: 'Loved it. Would buy again.', submittedAt: new Date().toISOString(), merchantReply: null, merchantRepliedAt: null, ...overrides, } } export const createShop = ( id: string, name: string, options?: { themeType?: 'coverImage' | 'brandColor' | 'logoColor' | 'none' withBrandSettings?: boolean primaryColor?: string logoDominantColor?: string logoAverageColor?: string coverDominantColor?: string wordmarkUrl?: string coverImageUrl?: string featuredImagesLimit?: number } ) => { // Determine theme configuration const themeType = options?.themeType || 'none' const shouldHaveBrandSettings = options?.withBrandSettings || themeType !== 'none' // Generate featured images const featuredImagesCount = options?.featuredImagesLimit || 3 const featuredImages = Array.from({length: featuredImagesCount}, (_, i) => ({ url: `https://picsum.photos/400/400?random=${id}-${i}`, sensitive: false, altText: `${name} featured image ${i + 1}`, })) // Configure colors based on theme type const getThemeColors = () => { switch (themeType) { case 'coverImage': return { primary: options?.primaryColor, logoDominant: options?.logoDominantColor, logoAverage: options?.logoAverageColor, coverDominant: options?.coverDominantColor || '#FF6B35', } case 'brandColor': return { primary: options?.primaryColor || '#27AE60', logoDominant: options?.logoDominantColor, logoAverage: options?.logoAverageColor, coverDominant: options?.coverDominantColor, } case 'logoColor': return { primary: options?.primaryColor, logoDominant: options?.logoDominantColor || '#E74C3C', logoAverage: options?.logoAverageColor, coverDominant: options?.coverDominantColor, } default: return { primary: options?.primaryColor, logoDominant: options?.logoDominantColor, logoAverage: options?.logoAverageColor, coverDominant: options?.coverDominantColor, } } } // Configure header theme const createHeaderTheme = () => { if (themeType === 'coverImage' || options?.coverImageUrl) { return { id: `header-theme-${id}`, coverImage: { url: options?.coverImageUrl || 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop', altText: `${name} cover image`, sensitive: false, thumbhash: 'k9oGHQRnh493V4dIeHeXh4h3iIeI', }, wordmark: options?.wordmarkUrl || themeType === 'coverImage' ? { url: options?.wordmarkUrl || 'https://merrypeople.com/cdn/shop/files/Transparent_Background_1.png?v=1696465429&width=1024', altText: `${name} wordmark`, sensitive: false, } : undefined, } } if (options?.wordmarkUrl) { return { id: `header-theme-${id}`, wordmark: { url: options.wordmarkUrl, altText: `${name} wordmark`, sensitive: false, }, } } return undefined } return { id, name, primaryDomain: { url: `https://${name.toLowerCase().replace(/\s+/g, '-')}.com`, }, reviewAnalytics: {averageRating: 4.3, reviewCount: 50}, visualTheme: { id: `visual-theme-${id}`, featuredImages, logoImage: { url: `https://picsum.photos/100/100?random=${id}`, sensitive: false, }, brandSettings: shouldHaveBrandSettings ? { id: `brand-settings-${id}`, colors: { id: `colors-${id}`, ...getThemeColors(), }, headerTheme: createHeaderTheme(), } : undefined, }, } } const createPagination = (hasNext = false) => ({ hasNextPage: hasNext, endCursor: hasNext ? 'cursor123' : null, }) const createProductList = (id: string, name: string, products: any[] = []) => ({ id, publicId: `public-${id}`, name, privacyStatus: 'PRIVATE' as const, products, }) // Helper type to extract the data type from a ShopAction type ShopActionDataType<T> = T extends ( ...args: any[] ) => Promise<{ok: true; data: infer R} | {ok: false; error: any}> ? R : never // Use window._mockLogs instead of console.log so logs aren't stripped in production builds // This allows e2e tests to verify mock actions are being called export interface MockLog { action: string params?: unknown } declare global { interface Window { _mockLogs?: MockLog[] } } function logMockAction(action: string, params?: unknown) { window._mockLogs = window._mockLogs || [] window._mockLogs.push({action, params}) } function makeMockMethod<K extends keyof ShopActions>( key: K, result: ShopActionDataType<ShopActions[K]> ): ShopActions[K] { return ((params: Parameters<ShopActions[K]>[0]) => { logMockAction(String(key), params) return Promise.resolve({ ok: true as const, data: result, mocked: true, }) }) as ShopActions[K] } export function makeMockActions(): ShopActions { const results: { [K in keyof ShopActions]: ShopActionDataType<ShopActions[K]> } = { translateContentUp: undefined, translateContentDown: undefined, followShop: true, unfollowShop: false, favorite: undefined, unfavorite: undefined, getShopAppInformation: { appVersion: '1.0.0', buildNumber: '12345', buildId: 'dev-build-123', }, productRecommendationImpression: undefined, productRecommendationClick: undefined, closeMini: undefined, getAccountInformation: { status: 'available', value: 'user@example.com', }, getCurrentUser: { data: { displayName: 'John Doe', avatarImage: {url: 'https://example.com/avatar.jpg'}, }, }, createOrderAttribution: undefined, addToCart: undefined, buyProduct: undefined, buyProducts: undefined, showErrorScreen: undefined, showErrorToast: undefined, getDeeplinkPaths: { matchers: ['/products', '/collections', '/cart'], }, navigateToDeeplink: undefined, navigateToShop: undefined, navigateToProduct: undefined, navigateToOrder: undefined, navigateToCheckout: undefined, createImageUploadLink: { // This action is mocked in the actual hook. See `useImageUpload` for more details. targets: [ { url: 'https://example.com/upload', resourceUrl: 'https://example.com/resource', parameters: [{name: 'key', value: 'upload-123'}], }, ], }, completeImageUpload: { files: [ { id: 'file-123', fileStatus: 'READY', image: { url: 'https://example.com/image.jpg', }, }, ], }, getPersistedItem: null, setPersistedItem: undefined, removePersistedItem: undefined, getAllPersistedKeys: ['key1', 'key2', 'key3'], clearPersistedItems: undefined, getInternalPersistedItem: null, setInternalPersistedItem: undefined, removeInternalPersistedItem: undefined, getAllInternalPersistedKeys: ['internal-key1', 'internal-key2'], clearInternalPersistedItems: undefined, getSecret: 'secret-value', setSecret: undefined, removeSecret: undefined, reportInteraction: undefined, reportImpression: undefined, reportContentImpression: undefined, getProductLists: { data: [ createProductList('list-1', 'Wishlist'), createProductList('list-2', 'Favorites'), ], pageInfo: createPagination(), }, getProductList: { data: createProductList('list-1', 'Wishlist', [ createProduct('prod-1', 'Sample Product'), ]), pageInfo: createPagination(), }, addProductList: createProductList('list-3', 'New List'), removeProductList: undefined, renameProductList: createProductList('list-1', 'Updated Wishlist'), setProductListVisibility: createProductList('list-1', 'Wishlist'), addProductListItem: undefined, removeProductListItem: undefined, getRecommendedProducts: { data: [ createProduct('rec-1', 'Recommended Product 1', '79.99'), createProduct('rec-2', 'Recommended Product 2', '129.99'), createProduct('rec-3', 'Recommended Product 3', '129.99'), createProduct('rec-4', 'Recommended Product 4', '29.99'), createProduct('rec-5', 'Recommended Product 5', '39.99'), createProduct('rec-6', 'Recommended Product 6', '49.99'), createProduct('rec-7', 'Recommended Product 7', '59.99'), createProduct('rec-8', 'Recommended Product 8', '69.99'), createProduct('rec-9', 'Recommended Product 9', '129.99'), ], pageInfo: createPagination(), }, getRecommendedShops: { data: [ createShop('shop-1', 'Amazing Store'), createShop('shop-2', 'Best Deals Shop'), createShop('shop-3', 'Great Products'), createShop('shop-4', 'Top Brands'), createShop('shop-5', 'Exclusive Offers'), ], pageInfo: createPagination(), }, searchProductsByShop: { data: [ createProduct('search-1', 'Search Result 1', '59.99'), createProduct('search-2', 'Search Result 2', '89.99'), createProduct('search-3', 'Search Result 3', '119.99'), createProduct('search-4', 'Search Result 4', '149.99'), createProduct('search-5', 'Search Result 5', '179.99'), ], pageInfo: createPagination(), }, getOrders: { data: [ { id: 'order-1', name: '#1001', lineItems: [ { productTitle: 'Sample Product', variantTitle: 'Medium', quantity: 2, product: null, }, ], shop: createShop('shop-1', 'Sample Shop'), }, ], pageInfo: createPagination(), }, getBuyerAttributes: { data: { genderAffinity: 'NEUTRAL' as Gender, categoryAffinities: [ {id: 'cat1', name: 'Electronics'}, {id: 'cat2', name: 'Clothing'}, ], }, }, showFeedbackSheet: undefined, getPopularProducts: { data: [ createProduct('pop-1', 'The Hero Snowboard', '702.95'), createProduct('pop-2', 'Snow Jacket', '605.95', '702.00'), createProduct('pop-3', 'Winter Gloves', '89.95'), createProduct('pop-4', 'Summer Gloves', '89.95'), createProduct('pop-5', 'Spring Gloves', '89.95'), createProduct('pop-6', 'Playstation 5', '499.95'), createProduct('pop-7', 'Xbox Series X', '499.95'), createProduct('pop-8', 'Nintendo Switch', '299.95'), createProduct('pop-9', 'Playstation 4', '299.95'), createProduct('pop-10', 'Nintendo 3DS', '89.95'), ], pageInfo: createPagination(), }, share: { message: 'Shared!', success: true, }, shareSingle: { message: 'Shared!', success: true, }, getSavedProducts: { data: [ createProduct('saved-1', 'Saved Product 1', '49.99'), createProduct('saved-2', 'Saved Product 2', '59.99'), createProduct('saved-3', 'Saved Product 3', '69.99'), createProduct('saved-4', 'Saved Product 4', '79.99'), createProduct('saved-5', 'Saved Product 5', '89.99'), ], pageInfo: createPagination(), }, getRecentProducts: { data: [ createProduct('recent-1', 'Recent Product 1', '59.99'), createProduct('recent-2', 'Recent Product 2', '69.99'), createProduct('recent-3', 'Recent Product 3', '79.99'), createProduct('recent-4', 'Recent Product 4', '89.99'), createProduct('recent-5', 'Recent Product 5', '99.99'), ], pageInfo: createPagination(), }, getProductSearch: { data: [ createProduct('search-1', 'Search Product 1', '39.99'), createProduct('search-2', 'Search Product 2', '19.99'), createProduct('search-3', 'Search Product 3', '29.99'), createProduct('search-4', 'Search Product 4', '49.99'), createProduct('search-5', 'Search Product 5', '9.99'), ], pageInfo: createPagination(), }, getProducts: { data: [ createProduct('prod-1', 'Product 1', '9.99'), createProduct('prod-2', 'Product 2', '19.99'), createProduct('prod-3', 'Product 3', '29.99'), createProduct('prod-4', 'Product 4', '39.99'), createProduct('prod-5', 'Product 5', '49.99'), ], }, getProduct: {data: createProduct('prod-1', 'Sample Product')}, getProductVariants: { data: [ { id: 'variant-1', title: 'Variant 1', isFavorited: false, image: {url: 'https://example.com/variant-1.jpg'}, price: {amount: '19.99', currencyCode: 'USD'}, compareAtPrice: {amount: '29.99', currencyCode: 'USD'}, }, ], pageInfo: createPagination(), }, getProductMedia: { data: [ { id: 'media-1', image: {url: 'https://example.com/media-1.jpg'}, mediaContentType: 'IMAGE', alt: 'Sample product image', }, ], pageInfo: createPagination(), }, getProductReviews: { data: [ createProductReview('review-1'), createProductReview('review-2', {rating: 4, title: 'Pretty good'}), createProductReview('review-3', { rating: 3, title: 'Okay', merchantReply: 'Thanks for the feedback!', merchantRepliedAt: new Date().toISOString(), }), ], pageInfo: createPagination(), }, getShop: { data: createShop('shop-1', 'Sample Shop', {featuredImagesLimit: 4}), }, getRecentShops: { data: [ createShop('recent-shop-1', 'Recent Shop 1'), createShop('recent-shop-2', 'Recent Shop 2'), createShop('recent-shop-3', 'Recent Shop 3'), ], pageInfo: createPagination(), }, getFollowedShops: { data: [ createShop('followed-shop-1', 'Followed Shop 1'), createShop('followed-shop-2', 'Followed Shop 2'), createShop('followed-shop-3', 'Followed Shop 3'), ], pageInfo: createPagination(), }, previewProductInAR: undefined, createContent: { data: { publicId: 'content-123', externalId: null, image: { id: 'img-123', url: 'https://cdn.shopify.com/s/files/1/0633/6574/2742/files/Namnlosdesign-47.png?v=1740438079', width: 800, height: 600, }, title: 'Mock Content', description: 'This is a mock content item', visibility: ['DISCOVERABLE'], shareableUrl: 'https://example.com/content/123', products: null, }, }, getContent: { data: [ { publicId: 'content-123', image: { id: 'img-123', url: 'https://cdn.shopify.com/s/files/1/0633/6574/2742/files/Namnlosdesign-47.png?v=1740438079', width: 800, height: 600, }, title: 'Mock Content', visibility: ['DISCOVERABLE'], status: MinisContentStatus.READY, }, ], }, generateUserToken: { data: { token: 'user-token-123', expiresAt: '2025-01-01', userState: UserState.VERIFIED, }, }, navigateToCart: undefined, requestPermission: { granted: true, }, reportError: undefined, reportFetch: undefined, } as const const mock: Partial<ShopActions> = {} for (const key in results) { if (Object.prototype.hasOwnProperty.call(results, key)) { // @ts-expect-error: dynamic assignment is safe due to exhaustive mapping mock[key] = makeMockMethod( key as keyof ShopActions, results[key as keyof typeof results] ) } } return mock as ShopActions } // Detect if running on a mobile device const isMobile = (): boolean => { const userAgent = navigator.userAgent.toLowerCase() const isIOS = /iphone|ipad|ipod/.test(userAgent) const isAndroid = /android/.test(userAgent) return isIOS || isAndroid } export const injectMocks = ({force}: {force?: boolean} = {}) => { // Only inject mocks if we aren't on a mobile device or we force it if (isMobile() && !force) { return } if (!window.minisSDK) { window.minisSDK = makeMockActions() window.minisParams = { handle: 'mock-handle', initialUrl: '/', platform: 'web', } } }