Skip to main content

Building a video mini

What will you learn?

In this guide, you'll learn how to do the following:

  • Accept data from an entry point.
  • Render a list of videos.
  • Show a product connected to each video.

Prerequisites

This guide assumes that you have your test store set up and are able to create entry points but mock data will be provided if you are just getting started. You could alternatively fetch this data from your own API if you wish.

Step 1: Set up your entry point

The first thing to do is to create an entry point with a list of videos and associated products. For this example I will be using the data that comes from the STORE_PAGE VIDEO_COLLECTION entry point.

You can create an entry point for this guide like so:

mutation EntryPointCreate {
entryPointSetByOwner(
shopDomain: "your-test-store.myshopify.com"
location: STORE_PAGE
ownerId: "gid://shopify/Shop/12345"
input: {
videoCollection: {
items: [
{
video: {
alt: "Ski slope chairlifts"
embedUrl: "https://cdn.shopify.com/videos/c/o/v/197d24d529fd4d5685d88b8e7a0e99b3.mp4"
}
fallbackImage: { url: "https://cdn.shopify.com/s/files/1/0621/0463/3599/files/preview_images/197d24d529fd4d5685d88b8e7a0e99b3.thumbnail.0000000000.jpg" }
contentCreator: {
avatar: { url: "https://cdn.shopify.com/s/files/1/0621/0463/3599/files/alex-starnes-WYE2UhXsU1Y-unsplash.jpg" }
name: "Sofia Rodriguez"
}
externalId: "gid://shopify/Product/54321"
}
# ... other items
]
}
}
) {
entryPoint {
id
}
userErrors {
code
field
message
}
}
}

Step 2: Reading entry point data

In your mini you can now use the useMinisParams() hook to get hold of entryPoint.collectionItems. When a mini is launched from a VIDEO_COLLECTION or IMAGE_COLLECTION entry point this object will contain all of the entry point items. We can use that for our first page of data instead of fetching it from an API which will help our mini first load to feel fast and responsive. Subsequent pages of data will need to be fetched from elsewhere.

We clean the data via the useVideos() hook which ensures we have a well-typed array of videos to work with.

import {ScrollView} from 'react-native'
import Video from 'react-native-video'
import {
Text,
VideoCollectionEntryPointItem,
useMinisParams,
} from '@shopify/shop-minis-platform-sdk'

const useVideos = (items?: Array<any>): VideoCollectionEntryPointItem[] => {
return useMemo(
() =>
(items ?? []).filter(
item => item.__typename === 'MiniVideoCollectionEntryPointItem'
),
[items]
)
}

export const HomeScreen = () => {
const {entryPoint} = useMinisParams()
const videos = useVideos(entryPoint?.collectionItems)

// Show an error message if we haven't got any videos to show
if (!videos.length) {
return <Text>No videos found. Please try again later</Text>
}

return (
<ScrollView>
{videos.map(video => (
<Video
key={video.video.embeddedUrl}
source={{uri: video.video.embeddedUrl}}
style={{height: 500}}
/>
))}
</ScrollView>
)
}

For now we just stack the videos on top of each other in a <ScrollView />:

As mentioned above mock data is provided if you cannot use entry point data yet. Just import entryPoint and entryPointParams from mockData.ts instead of using useMinisParams(). This mock data assumes you are using enableApiSandbox so toggle that back on if you previously disabled it.

Step 3: Add styles

We already have our videos playing but we only want to see one at a time and we want them to take up the full screen so let's add some styling.

We need some wrapper styles to set a background color for our main view as well as our error state

const wrapperStyles: StyleProp<ViewStyle> = {
flex: 1,
backgroundColor: 'black',
}

So we can update the error state

if (!videos.length) {
return (
<SafeAreaView style={wrapperStyles}>
<Box flex={1} alignItems="center" justifyContent="center">
<Text style={{color: 'white'}}>
No videos found. Please try again later
</Text>
</Box>
</SafeAreaView>
)
}

And the main view

return (
<SafeAreaView style={wrapperStyles}>
<ScrollView>
{videos.map(video => (
<Video
key={video.video.embeddedUrl}
source={{uri: video.video.embeddedUrl}}
style={{height: 500}}
/>
))}
</ScrollView>
</SafeAreaView>
)

Stretching the <Video /> to take up the full screen is next. We can flex to the full width of the screen easily but because we are rendering inside a <ScrollView /> we cannot easily flex to the full height automatically. If you are familiar with react-native you might think that something like Dimensions.get('window').height is the solution but because Shop Minis render inside a frame this number will not be correct and will lead to content overflow if used.

The solution is to use the onLayout prop from react-native. This allows us to get a dynamic height value from any <View />. We will store a height value, update it from onLayout and use it to set the height of our videos.

const [height, setHeight] = useState<number | null>(null)

const onLayout = useCallback((event: LayoutChangeEvent) => {
setHeight(event.nativeEvent.layout.height)
}, [])

// Render an initial wrapper to get the height
if (!height) {
return <SafeAreaView style={wrapperStyles} onLayout={onLayout} />
}

// Now we can use `height` to render the <Video /> ...

When you stretch the video to the correct size you might notice black bars around the video, that is because react-native-video defaults to a resize mode that shows the whole video inside it's container. Luckily this component has a lot of props we can use to tweak its behaviour. We will use a resizeMode of cover. Whilst we are here we will add a poster so the user will see something while the video is downloading.

<Video
key={video.video.embeddedUrl}
source={{uri: video.video.embeddedUrl}}
resizeMode="cover"
poster={video.fallbackImage.url}
posterResizeMode="cover"
repeat
style={{height}}
/>

So with those updates our HomeScreen.tsx is looking much better

import {useCallback, useState} from 'react'
import {
ScrollView,
StyleProp,
ViewStyle,
SafeAreaView,
LayoutChangeEvent,
} from 'react-native'
import Video from 'react-native-video'
import {
Box,
Text,
useMinisParams,
VideoCollectionEntryPointItem,
} from '@shopify/shop-minis-platform-sdk'

const useVideos = (items?: Array<any>): VideoCollectionEntryPointItem[] => {
return useMemo(
() =>
(items ?? []).filter(
item => item.__typename === 'MiniVideoCollectionEntryPointItem'
),
[items]
)
}

export const HomeScreen = () => {
const {entryPoint} = useMinisParams()
const [height, setHeight] = useState<number | null>(null)

const wrapperStyles: StyleProp<ViewStyle> = {
flex: 1,
backgroundColor: 'black',
}
const videos = useVideos(entryPoint?.collectionItems)

const onLayout = useCallback((event: LayoutChangeEvent) => {
setHeight(event.nativeEvent.layout.height)
}, [])

// Render an initial wrapper to get the height
if (!height) {
return <SafeAreaView style={wrapperStyles} onLayout={onLayout} />
}

// Show an error message if we haven't got any videos to show
if (!videos.length) {
return (
<SafeAreaView style={wrapperStyles}>
<Box flex={1} alignItems="center" justifyContent="center">
<Text style={{color: 'white'}}>
No videos found. Please try again later
</Text>
</Box>
</SafeAreaView>
)
}

return (
<SafeAreaView style={wrapperStyles}>
<ScrollView>
{videos.map(video => (
<Video
key={video.video.embeddedUrl}
source={{uri: video.video.embeddedUrl}}
resizeMode="cover"
poster={video.fallbackImage.url}
posterResizeMode="cover"
repeat
style={{height}}
/>
))}
</ScrollView>
</SafeAreaView>
)
}

Step 4: Add products

So far we are showing lovely video content but what we really want is to link our videos to products that can be easily purchased through our mini app. We will use the shop minis api to fetch full product data from our list of product ids then feed that data into the <ProductLink /> component.

First let's start with the query:

query VideoMiniFetchProducts($shopId: ID!, $productIds: [ID!]!) {
shop(id: $shopId) {
id
name
productsByIds(ids: $productIds) {
id
title
tags
featuredImage {
id
altText
url
}
defaultVariant {
id
title
isFavorited
compareAtPrice {
amount
currencyCode
}
price {
amount
currencyCode
}
image {
id
altText
url
}
}
variants(first: 1) {
nodes {
id
title
isFavorited
compareAtPrice {
amount
currencyCode
}
price {
amount
currencyCode
}
image {
id
altText
url
}
}
}
}
}
}

After making the changes, run:

npx shop-minis generate-graphql-types

Then we can use useMinisQuery() to execute the query. We will extract product ids from the externalId that is attached to each entry point collection item.

import FetchProductsQuery from './FetchProducts.graphql'
const shopGID = entryPointParams?.shopGID ?? null
const productGIDs = videos
.map(video => video.externalId ?? null)
.filter((id): id is string => id !== null)
const canQuery = shopGID && productGIDs.length > 0

const {data: productData} = useMinisQuery(FetchProductsQuery, {
variables: {
shopId: shopGID!,
productIds: productGIDs,
},
fetchPolicy: 'cache-and-network',
skip: !canQuery,
})

const products = productData?.shop?.productsByIds ?? []

We will update the <Video /> render to add a wrapper and a <ProductLink /> which will appear once the products query has completed and a product has been found.

const product = products.find(item => item?.id === video.externalId)

return (
<Box height={height} key={video.video.embeddedUrl}>
<Video
source={{uri: video.video.embeddedUrl}}
resizeMode="cover"
poster={video.fallbackImage.url}
posterResizeMode="cover"
repeat
style={{flex: 1}}
/>

{product ? (
<Box position="absolute" bottom={0} left={0} right={0} margin="m">
<ProductLink product={product} />
</Box>
) : null}
</Box>

Let's see where we are so far:

import {useCallback, useState, useMemo} from 'react'
import {
ScrollView,
StyleProp,
ViewStyle,
SafeAreaView,
LayoutChangeEvent,
} from 'react-native'
import Video from 'react-native-video'
import {
Box,
ProductLink,
Text,
useMinisParams,
useMinisQuery,
VideoCollectionEntryPointItem,
} from '@shopify/shop-minis-platform-sdk'

import FetchProductsQuery from './FetchProducts.graphql'

const useVideos = (items?: Array<any>): VideoCollectionEntryPointItem[] => {
return useMemo(
() =>
(items ?? []).filter(
item => item.__typename === 'MiniVideoCollectionEntryPointItem'
),
[items]
)
}

export const HomeScreen = () => {
const {entryPoint, entryPointParams} = useMinisParams()
const [height, setHeight] = useState<number | null>(null)

const wrapperStyles: StyleProp<ViewStyle> = {
flex: 1,
backgroundColor: 'black',
}
const videos = useVideos(entryPoint?.collectionItems)
const shopGID = entryPointParams?.shopGID ?? null
const productGIDs = videos
.map(video => video.externalId ?? null)
.filter((id): id is string => id !== null)
const canQuery = shopGID && productGIDs.length > 0

const {data: productData} = useMinisQuery(FetchProductsQuery, {
variables: {
shopId: shopGID!,
productIds: productGIDs,
},
fetchPolicy: 'cache-and-network',
skip: !canQuery,
})

const products = useMemo(
() => productData?.shop?.productsByIds ?? [],
[productData?.shop?.productsByIds]
)

const onLayout = useCallback((event: LayoutChangeEvent) => {
setHeight(event.nativeEvent.layout.height)
}, [])

// Render an initial wrapper to get the height
if (!height) {
return <SafeAreaView style={wrapperStyles} onLayout={onLayout} />
}

// Show an error message if we haven't got any videos to show
if (!videos.length) {
return (
<SafeAreaView style={wrapperStyles}>
<Box flex={1} alignItems="center" justifyContent="center">
<Text style={{color: 'white'}}>
No videos found. Please try again later
</Text>
</Box>
</SafeAreaView>
)
}

return (
<SafeAreaView style={wrapperStyles}>
<ScrollView>
{videos.map(video => {
const product = products.find(item => item?.id === video.externalId)

return (
<Box height={height} key={video.video.embeddedUrl}>
<Video
source={{uri: video.video.embeddedUrl}}
resizeMode="cover"
poster={video.fallbackImage.url}
posterResizeMode="cover"
repeat
style={{flex: 1}}
/>

{product ? (
<Box position="absolute" bottom={0} left={0} right={0} margin="m">
<ProductLink product={product} />
</Box>
) : null}
</Box>
)
})}
</ScrollView>
</SafeAreaView>
)
}

Step 5: Finishing touches

Things are looking pretty good so far but it would be great if the scroll could snap to the next video. It would also be much better for performance if we only play a single video at a time.

To implement these changes we will switch from <ScrollView /> to <FlatList /> which supports scroll snapping and has visibility events to let us pause/unpause as the user scrolls. The API is quite different, <FlatList /> uses props instead of children, but the changes are not so bad, the biggest change is moving to the data/renderItem pattern:

<FlatList
style={{flex: 1}}
// These props take care of what to render
data={videos}
renderItem={renderVideoItem}
// These props control the scroll snapping
snapToInterval={height}
decelerationRate="fast"
// These props allow us to get a callback when a video becomes visible
viewabilityConfig={{viewAreaCoveragePercentThreshold: 50}}
onViewableItemsChanged={onViewableItemsChanged}
/>

renderVideoItem should look familiar as it's basically the same video rendering function as before

const renderVideoItem = useCallback(
({item: renderItem}: ListRenderItemInfo<VideoCollectionEntryPointItem>) => {
const {video, fallbackImage, externalId} = renderItem
const product = products.find(
productItem => productItem?.id === externalId
)

if (!height || !video.embeddedUrl) return null

return (
<Box height={height} key={video.embeddedUrl}>
<Video
source={{uri: video.embeddedUrl}}
resizeMode="cover"
poster={fallbackImage.url}
posterResizeMode="cover"
repeat
style={{flex: 1}}
/>

{product ? (
<Box position="absolute" bottom={0} left={0} right={0} margin="m">
<ProductLink product={product} />
</Box>
) : null}
</Box>
)
},
[products, height]
)

And onViewableItemsChanged simply updates visibleIndex which is used to pause/unpause the videos

const [visibleIndex, setVisibleIndex] = useState(0)

const onViewableItemsChanged = useCallback(
(info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
if (typeof info.viewableItems[0].index !== 'number') return

setVisibleIndex(info.viewableItems[0].index)
},
[]
)

So far the videos always load in the same order regardless of which item the user pressed in the entry point. When the mini is opened we receive an entryPointParams?.externalID that will match the externalId of one of the items in entryPoint?.collectionItems. We can modify useVideos() to place that item at the start of the array:

const useVideos = (
items?: Array<any>,
targetExternalId?: string
): VideoCollectionEntryPointItem[] => {
return useMemo(
() =>
(items ?? [])
.filter(item => item.__typename === 'MiniVideoCollectionEntryPointItem')
.sort(a => (a.externalId === targetExternalId ? -1 : 0)),
[items, targetExternalId]
)
}
const videos = useVideos(
entryPoint?.collectionItems,
entryPointParams?.externalID
)

Let's put it all together and see how it looks

import {useCallback, useState, useMemo} from 'react'
import {
FlatList,
StyleProp,
ViewStyle,
SafeAreaView,
LayoutChangeEvent,
ListRenderItemInfo,
ViewToken,
} from 'react-native'
import Video from 'react-native-video'
import {
Box,
ProductLink,
Text,
useMinisParams,
useMinisQuery,
VideoCollectionEntryPointItem,
} from '@shopify/shop-minis-platform-sdk'

import FetchProductsQuery from './FetchProducts.graphql'

const useVideos = (
items?: Array<any>,
targetExternalId?: string
): VideoCollectionEntryPointItem[] => {
return useMemo(
() =>
(items ?? [])
.filter(item => item.__typename === 'MiniVideoCollectionEntryPointItem')
.sort(a => (a.externalId === targetExternalId ? -1 : 0)),
[items, targetExternalId]
)
}

export const HomeScreen = () => {
const {entryPoint, entryPointParams} = useMinisParams()
const [height, setHeight] = useState<number | null>(null)
const [visibleIndex, setVisibleIndex] = useState(0)

const wrapperStyles: StyleProp<ViewStyle> = {
flex: 1,
backgroundColor: 'black',
}
const videos = useVideos(
entryPoint?.collectionItems,
entryPointParams?.externalID
)
const shopGID = entryPointParams?.shopGID ?? null
const productGIDs = videos
.map(video => video.externalId ?? null)
.filter((id): id is string => id !== null)
const canQuery = shopGID && productGIDs.length > 0

const {data: productData} = useMinisQuery(FetchProductsQuery, {
variables: {
shopId: shopGID!,
productIds: productGIDs,
},
fetchPolicy: 'cache-and-network',
skip: !canQuery,
})

const products = useMemo(
() => productData?.shop?.productsByIds ?? [],
[productData?.shop?.productsByIds]
)

const renderVideoItem = useCallback(
({
item: renderItem,
index,
}: ListRenderItemInfo<VideoCollectionEntryPointItem>) => {
const {video, fallbackImage, externalId} = renderItem
const isVisible = index === visibleIndex
const product = products.find(
productItem => productItem?.id === externalId
)

if (!height || !video.embeddedUrl) return null

return (
<Box height={height} key={video.embeddedUrl}>
<Video
source={{uri: video.embeddedUrl}}
resizeMode="cover"
poster={fallbackImage.url}
posterResizeMode="cover"
repeat
paused={!isVisible}
style={{flex: 1}}
/>

{product ? (
<Box position="absolute" bottom={0} left={0} right={0} margin="m">
<ProductLink product={product} />
</Box>
) : null}
</Box>
)
},
[visibleIndex, products, height]
)

const onLayout = useCallback((event: LayoutChangeEvent) => {
setHeight(event.nativeEvent.layout.height)
}, [])

const onViewableItemsChanged = useCallback(
(info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
if (typeof info.viewableItems[0].index !== 'number') return

setVisibleIndex(info.viewableItems[0].index)
},
[]
)

// Render an initial wrapper to get the height
if (!height) {
return <SafeAreaView style={wrapperStyles} onLayout={onLayout} />
}

// Show an error message if we haven't got any videos to show
if (!videos.length) {
return (
<SafeAreaView style={wrapperStyles}>
<Box flex={1} alignItems="center" justifyContent="center">
<Text style={{color: 'white'}}>
No videos found. Please try again later
</Text>
</Box>
</SafeAreaView>
)
}

return (
<SafeAreaView style={wrapperStyles}>
<FlatList
style={{flex: 1}}
data={videos}
renderItem={renderVideoItem}
snapToInterval={height}
decelerationRate="fast"
viewabilityConfig={{viewAreaCoveragePercentThreshold: 50}}
onViewableItemsChanged={onViewableItemsChanged}
/>
</SafeAreaView>
)
}

We have the beginnings of what could be an awesome mini here but it could be improved in many ways. Here's some ideas to get you started:

  • A loading indicator
  • A progress bar
  • Skip to the next video on video completion
  • Tap/hold to pause/unpause
  • Video preloading

We look forward to seeing what you build!