@shopify/shop-minis-react
Version:
React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)
168 lines (140 loc) • 4.02 kB
text/typescript
import {useCallback} from 'react'
import {useShopActions} from '../../internal/useShopActions'
import {fileToDataUri} from '../../utils'
import type {UploadTarget} from '@shopify/shop-minis-platform/actions'
export interface UploadImageParams {
/**
* The file to upload.
*/
image: File
}
interface ProcessedImage {
/**
* The MIME type of the image.
*/
mimeType: string
/**
* The size of the image in bytes.
*/
fileSize: number
/**
* The file blob of the image.
*/
fileBlob: Blob
}
export interface UploadedImage {
/**
* The ID of the uploaded image.
*/
id: string
/**
* The URL of the uploaded image.
*/
imageUrl?: string
/**
* The resource URL of the uploaded image.
*/
resourceUrl?: string
}
interface UseImageUploadReturns {
/**
* Upload an image which will be attached to the current user.
*/
uploadImage: (image: File) => Promise<UploadedImage[]>
}
// Fetch file data and detect file sizes if not provided
// Works with file://, data:, and http(s):// URIs
const processFileData = async (image: File): Promise<ProcessedImage> => {
const uri = await fileToDataUri(image)
const response = await fetch(uri)
const blob = await response.blob()
return {
mimeType: image.type,
fileSize: image.size ?? blob.size,
fileBlob: blob,
}
}
const uploadFileToGCS = async (image: ProcessedImage, target: UploadTarget) => {
const formData = new FormData()
target.parameters.forEach(({name, value}: {name: string; value: string}) => {
formData.append(name, value)
})
formData.append('file', image.fileBlob)
const uploadResponse = await fetch(target.url, {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
console.error('Failed to upload image', {
response: await uploadResponse.text(),
})
return {error: 'Failed to upload image'}
}
return {}
}
export const useImageUpload = (): UseImageUploadReturns => {
const {createImageUploadLink, completeImageUpload} = useShopActions()
const uploadImage = useCallback(
async (image: File) => {
const processedImageParams = await processFileData(image)
const links = await createImageUploadLink({
input: [
{
mimeType: processedImageParams.mimeType,
fileSize: processedImageParams.fileSize,
},
],
})
if (!links.ok) {
throw new Error(links.error.message)
}
if (links.mocked) {
// Skip upload and return mock data
return [
{
id: 'uploaded-image-id',
imageUrl:
'https://cdn.shopify.com/s/files/1/0621/0463/3599/files/Mr._Bean_2007_800x800.jpg?v=1763126175',
resourceUrl:
'https://cdn.shopify.com/s/files/1/0621/0463/3599/files/Mr._Bean_2007_800x800.jpg?v=1763126175',
},
]
}
// Upload single file to GCS
const {error: uploadError} = await uploadFileToGCS(
processedImageParams,
links?.data?.targets?.[0]!
)
if (uploadError) {
throw new Error(uploadError)
}
// 10 second polling for image upload
let count = 0
while (count < 30) {
const result = await completeImageUpload({
resourceUrls:
links?.data?.targets?.map(target => target.resourceUrl) || [],
})
if (!result.ok) {
throw new Error(result.error.message)
}
if (result.data?.files?.[0]?.fileStatus === 'READY') {
return [
{
id: result.data.files[0].id,
imageUrl: result.data.files[0].image?.url,
resourceUrl: links?.data?.targets?.[0]?.resourceUrl,
},
]
}
await new Promise(resolve => setTimeout(resolve, 1000))
count++
}
throw new Error('Image upload completion timed out')
},
[createImageUploadLink, completeImageUpload]
)
return {
uploadImage,
}
}