@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
text/typescript
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',
}
}
}