Collaborative Studios

import { render } from 'preact'; import { useState, useEffect, useRef } from 'preact/hooks'; interface Artist { id: string; handle: string; artistName: string; imageUrl: string; linkUrl: string; } interface PageInfo { hasNextPage: boolean; endCursor: string | null; } const CHECK_DEFINITION_QUERY = ` query CheckArtistDefinition { metaobjectDefinitionByType(type: "sidekick_artist_showcase") { id } } `; const CREATE_DEFINITION_MUTATION = ` mutation CreateArtistShowcaseDefinition($definition: MetaobjectDefinitionCreateInput!) { metaobjectDefinitionCreate(definition: $definition) { metaobjectDefinition { id } userErrors { field message } } } `; const GET_ARTISTS_QUERY = ` query GetArtists($first: Int!, $after: String) { metaobjects(type: "sidekick_artist_showcase", first: $first, after: $after) { edges { node { id handle artistName: field(key: "artist_name") { value } imageUrl: field(key: "image_url") { value } linkUrl: field(key: "link_url") { value } } } pageInfo { hasNextPage endCursor } } } `; const STAGED_UPLOADS_CREATE_MUTATION = ` mutation StagedUploadsCreate($input: [StagedUploadInput!]!) { stagedUploadsCreate(input: $input) { stagedTargets { url resourceUrl parameters { name value } } userErrors { field message } } } `; const FILE_CREATE_MUTATION = ` mutation FileCreate($files: [FileCreateInput!]!) { fileCreate(files: $files) { files { id fileStatus ... on MediaImage { image { url } } } userErrors { field message } } } `; const POLL_FILE_QUERY = ` query PollFile($id: ID!) { node(id: $id) { ... on MediaImage { id fileStatus image { url } } } } `; const UPSERT_ARTIST_MUTATION = ` mutation UpsertArtist($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) { metaobjectUpsert(handle: $handle, metaobject: $metaobject) { metaobject { id handle } userErrors { field message } } } `; const DELETE_ARTIST_MUTATION = ` mutation DeleteArtist($id: ID!) { metaobjectDelete(id: $id) { deletedId userErrors { field message } } } `; function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 40); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function Extension() { const [setupLoading, setSetupLoading] = useState(true); const [setupError, setSetupError] = useState(''); const [artists, setArtists] = useState([]); const [listLoading, setListLoading] = useState(false); const [listError, setListError] = useState(''); const [deleteError, setDeleteError] = useState(''); // Add modal state const [addModalOpen, setAddModalOpen] = useState(false); const [addArtistName, setAddArtistName] = useState(''); const [addImageFile, setAddImageFile] = useState(null); const [addImageUrl, setAddImageUrl] = useState(''); const [addImageMode, setAddImageMode] = useState<'upload' | 'url'>('upload'); const [addLinkUrl, setAddLinkUrl] = useState(''); const [addSaving, setAddSaving] = useState(false); const [addError, setAddError] = useState(''); const [addNameError, setAddNameError] = useState(''); const [addImageError, setAddImageError] = useState(''); const [addLinkError, setAddLinkError] = useState(''); // Edit modal state const [editModalOpen, setEditModalOpen] = useState(false); const [editArtist, setEditArtist] = useState(null); const [editArtistName, setEditArtistName] = useState(''); const [editImageFile, setEditImageFile] = useState(null); const [editImageUrl, setEditImageUrl] = useState(''); const [editImageMode, setEditImageMode] = useState<'upload' | 'url'>('upload'); const [editLinkUrl, setEditLinkUrl] = useState(''); const [editSaving, setEditSaving] = useState(false); const [editError, setEditError] = useState(''); const [editNameError, setEditNameError] = useState(''); const [editImageError, setEditImageError] = useState(''); const [editLinkError, setEditLinkError] = useState(''); // Delete modal state const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [artistToDelete, setArtistToDelete] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const addModalRef = useRef(null); const editModalRef = useRef(null); const deleteModalRef = useRef(null); useEffect(() => { initializeApp(); }, []); async function initializeApp(): Promise { setSetupLoading(true); setSetupError(''); try { const { data, errors } = await shopify.query(CHECK_DEFINITION_QUERY); if (errors && errors.length > 0) { setSetupError(errors.map((e: any) => e.message).join(', ')); setSetupLoading(false); return; } if (!data?.metaobjectDefinitionByType?.id) { const { data: createData, errors: createErrors } = await shopify.query( CREATE_DEFINITION_MUTATION, { variables: { definition: { name: 'Artist Showcase', type: 'sidekick_artist_showcase', access: { storefront: 'NONE' }, fieldDefinitions: [ { name: 'Artist Name', key: 'artist_name', type: 'single_line_text_field' }, { name: 'Image URL', key: 'image_url', type: 'single_line_text_field' }, { name: 'Link URL', key: 'link_url', type: 'url' }, ], }, }, }, ); if (createErrors && createErrors.length > 0) { setSetupError(createErrors.map((e: any) => e.message).join(', ')); setSetupLoading(false); return; } if (createData?.metaobjectDefinitionCreate?.userErrors?.length > 0) { setSetupError( createData.metaobjectDefinitionCreate.userErrors.map((e: any) => e.message).join(', '), ); setSetupLoading(false); return; } } setSetupLoading(false); await loadArtists(); } catch (err: any) { setSetupError(err.message || 'Failed to initialize app.'); setSetupLoading(false); } } async function loadArtists(): Promise { setListLoading(true); setListError(''); const allArtists: Artist[] = []; let cursor: string | null = null; let hasNext = true; let pageCount = 0; try { while (hasNext && pageCount < 5) { pageCount++; const { data, errors } = await shopify.query(GET_ARTISTS_QUERY, { variables: { first: 50, after: cursor }, }); if (errors && errors.length > 0) { setListError(errors.map((e: any) => e.message).join(', ')); setListLoading(false); return; } const metaobjects = data?.metaobjects; if (metaobjects?.edges) { for (const edge of metaobjects.edges) { const node = edge.node; allArtists.push({ id: node.id, handle: node.handle, artistName: node.artistName?.value || '', imageUrl: node.imageUrl?.value || '', linkUrl: node.linkUrl?.value || '', }); } } const pageInfo: PageInfo = metaobjects?.pageInfo; hasNext = pageInfo?.hasNextPage || false; cursor = pageInfo?.endCursor || null; } setArtists(allArtists); } catch (err: any) { setListError(err.message || 'Failed to load artists.'); } finally { setListLoading(false); } } async function uploadImageFile(file: File): Promise { // Step 1: Create staged upload const { data: stageData, errors: stageErrors } = await shopify.query( STAGED_UPLOADS_CREATE_MUTATION, { variables: { input: [ { resource: 'IMAGE', filename: file.name, mimeType: file.type, httpMethod: 'POST', }, ], }, }, ); if (stageErrors && stageErrors.length > 0) { throw new Error(stageErrors.map((e: any) => e.message).join(', ')); } if (stageData?.stagedUploadsCreate?.userErrors?.length > 0) { throw new Error( stageData.stagedUploadsCreate.userErrors.map((e: any) => e.message).join(', '), ); } const target = stageData?.stagedUploadsCreate?.stagedTargets?.[0]; if (!target) { throw new Error('No staged upload target returned.'); } // Step 2: Upload file via fetch const formData = new FormData(); for (const param of target.parameters) { formData.append(param.name, param.value); } formData.append('file', file); const uploadResponse = await fetch(target.url, { method: 'POST', body: formData, }); if (!uploadResponse.ok) { throw new Error('Image upload failed. Please try again.'); } // Step 3: Create file in Shopify const { data: fileData, errors: fileErrors } = await shopify.query(FILE_CREATE_MUTATION, { variables: { files: [ { originalSource: target.resourceUrl, contentType: 'IMAGE', alt: file.name, duplicateResolutionMode: 'APPEND_UUID', }, ], }, }); if (fileErrors && fileErrors.length > 0) { throw new Error(fileErrors.map((e: any) => e.message).join(', ')); } if (fileData?.fileCreate?.userErrors?.length > 0) { throw new Error(fileData.fileCreate.userErrors.map((e: any) => e.message).join(', ')); } const createdFile = fileData?.fileCreate?.files?.[0]; if (!createdFile) { throw new Error('File creation failed.'); } // Step 4: Poll until READY const fileId = createdFile.id; for (let i = 0; i < 10; i++) { await sleep(1500); const { data: pollData, errors: pollErrors } = await shopify.query(POLL_FILE_QUERY, { variables: { id: fileId }, }); if (pollErrors && pollErrors.length > 0) { throw new Error('Unable to check image status. Please try again.'); } const node = pollData?.node; if (node?.fileStatus === 'READY' && node?.image?.url) { return node.image.url as string; } } throw new Error('Image processing timed out. Please try again.'); } function validateAddForm(): boolean { let valid = true; if (!addArtistName.trim()) { setAddNameError('Artist name is required.'); valid = false; } else { setAddNameError(''); } if (addImageMode === 'upload' && !addImageFile) { setAddImageError('Please upload an image.'); valid = false; } else if (addImageMode === 'url' && !addImageUrl.trim()) { setAddImageError('Please enter an image URL.'); valid = false; } else { setAddImageError(''); } if (!addLinkUrl.trim()) { setAddLinkError('Link URL is required.'); valid = false; } else { setAddLinkError(''); } return valid; } function validateEditForm(): boolean { let valid = true; if (!editArtistName.trim()) { setEditNameError('Artist name is required.'); valid = false; } else { setEditNameError(''); } if (editImageMode === 'upload' && !editImageFile && !editArtist?.imageUrl) { setEditImageError('Please upload an image.'); valid = false; } else if (editImageMode === 'url' && !editImageUrl.trim()) { setEditImageError('Please enter an image URL.'); valid = false; } else { setEditImageError(''); } if (!editLinkUrl.trim()) { setEditLinkError('Link URL is required.'); valid = false; } else { setEditLinkError(''); } return valid; } async function handleAddSave(): Promise { if (!validateAddForm()) return; setAddSaving(true); setAddError(''); try { let finalImageUrl = ''; if (addImageMode === 'upload' && addImageFile) { finalImageUrl = await uploadImageFile(addImageFile); } else { finalImageUrl = addImageUrl.trim(); } const handle = `${slugify(addArtistName)}-${Date.now()}`; const { data, errors } = await shopify.query(UPSERT_ARTIST_MUTATION, { variables: { handle: { type: 'sidekick_artist_showcase', handle }, metaobject: { fields: [ { key: 'artist_name', value: addArtistName.trim() }, { key: 'image_url', value: finalImageUrl }, { key: 'link_url', value: addLinkUrl.trim() }, ], }, }, }); if (errors && errors.length > 0) { setAddError(errors.map((e: any) => e.message).join(', ')); return; } if (data?.metaobjectUpsert?.userErrors?.length > 0) { setAddError(data.metaobjectUpsert.userErrors.map((e: any) => e.message).join(', ')); return; } closeAddModal(); await loadArtists(); } catch (err: any) { setAddError(err.message || 'Failed to save artist.'); } finally { setAddSaving(false); } } async function handleEditSave(): Promise { if (!editArtist) return; if (!validateEditForm()) return; setEditSaving(true); setEditError(''); try { let finalImageUrl = editArtist.imageUrl; if (editImageMode === 'upload' && editImageFile) { finalImageUrl = await uploadImageFile(editImageFile); } else if (editImageMode === 'url') { finalImageUrl = editImageUrl.trim(); } const { data, errors } = await shopify.query(UPSERT_ARTIST_MUTATION, { variables: { handle: { type: 'sidekick_artist_showcase', handle: editArtist.handle }, metaobject: { fields: [ { key: 'artist_name', value: editArtistName.trim() }, { key: 'image_url', value: finalImageUrl }, { key: 'link_url', value: editLinkUrl.trim() }, ], }, }, }); if (errors && errors.length > 0) { setEditError(errors.map((e: any) => e.message).join(', ')); return; } if (data?.metaobjectUpsert?.userErrors?.length > 0) { setEditError(data.metaobjectUpsert.userErrors.map((e: any) => e.message).join(', ')); return; } closeEditModal(); await loadArtists(); } catch (err: any) { setEditError(err.message || 'Failed to update artist.'); } finally { setEditSaving(false); } } async function handleDeleteConfirm(): Promise { if (!artistToDelete) return; setDeleteLoading(true); setDeleteError(''); try { const { data, errors } = await shopify.query(DELETE_ARTIST_MUTATION, { variables: { id: artistToDelete.id }, }); if (errors && errors.length > 0) { setDeleteError(errors.map((e: any) => e.message).join(', ')); setDeleteLoading(false); return; } if (data?.metaobjectDelete?.userErrors?.length > 0) { setDeleteError(data.metaobjectDelete.userErrors.map((e: any) => e.message).join(', ')); setDeleteLoading(false); return; } closeDeleteModal(); await loadArtists(); } catch (err: any) { setDeleteError(err.message || 'Failed to delete artist.'); } finally { setDeleteLoading(false); } } function openAddModal(): void { setAddArtistName(''); setAddImageFile(null); setAddImageUrl(''); setAddImageMode('upload'); setAddLinkUrl(''); setAddError(''); setAddNameError(''); setAddImageError(''); setAddLinkError(''); setAddModalOpen(true); if (addModalRef.current?.showOverlay) addModalRef.current.showOverlay(); } function closeAddModal(): void { setAddModalOpen(false); if (addModalRef.current?.hideOverlay) addModalRef.current.hideOverlay(); } function openEditModal(artist: Artist): void { setEditArtist(artist); setEditArtistName(artist.artistName); setEditImageFile(null); setEditImageUrl(artist.imageUrl); setEditImageMode('url'); setEditLinkUrl(artist.linkUrl); setEditError(''); setEditNameError(''); setEditImageError(''); setEditLinkError(''); setEditModalOpen(true); if (editModalRef.current?.showOverlay) editModalRef.current.showOverlay(); } function closeEditModal(): void { setEditModalOpen(false); if (editModalRef.current?.hideOverlay) editModalRef.current.hideOverlay(); } function openDeleteModal(artist: Artist): void { setArtistToDelete(artist); setDeleteError(''); setDeleteModalOpen(true); if (deleteModalRef.current?.showOverlay) deleteModalRef.current.showOverlay(); } function closeDeleteModal(): void { setDeleteModalOpen(false); setArtistToDelete(null); if (deleteModalRef.current?.hideOverlay) deleteModalRef.current.hideOverlay(); } if (setupLoading) { return ( Setting up Artist Showcase... ); } if (setupError) { return ( {setupError} ); } return ( {/* Header row */} Artist Showcase Add artist {/* Errors */} {listError && ( {listError} )} {deleteError && ( {deleteError} )} {/* Loading */} {listLoading && ( Loading artists... )} {/* Empty state */} {!listLoading && artists.length === 0 && ( No artists yet Add your first artist using the button above. Add artist )} {/* Artist grid */} {!listLoading && artists.length > 0 && ( {artists.map((artist) => ( {artist.artistName} {artist.linkUrl && ( Visit link )} openEditModal(artist)}> Edit openDeleteModal(artist)} > Delete ))} )} {/* Add Artist Modal */} {addError && ( {addError} )} setAddArtistName(e.currentTarget.value)} error={addNameError} required /> Image source { setAddImageMode('upload'); setAddImageError(''); }} > Upload file { setAddImageMode('url'); setAddImageError(''); }} > Enter URL {addImageMode === 'upload' && ( { const files = e.currentTarget?.files; if (files && files.length > 0) { setAddImageFile(files[0]); setAddImageError(''); } }} /> )} {addImageMode === 'url' && ( setAddImageUrl(e.currentTarget.value)} error={addImageError} placeholder="https://example.com/image.jpg" /> )} {addImageFile && addImageMode === 'upload' && ( Selected: {addImageFile.name} )} setAddLinkUrl(e.currentTarget.value)} error={addLinkError} placeholder="https://example.com" required /> Save artist Cancel {/* Edit Artist Modal */} {editError && ( {editError} )} setEditArtistName(e.currentTarget.value)} error={editNameError} required /> Image source {editArtist?.imageUrl && editImageMode === 'url' && ( Current image: )} { setEditImageMode('upload'); setEditImageError(''); }} > Upload new file { setEditImageMode('url'); setEditImageError(''); }} > Enter URL {editImageMode === 'upload' && ( { const files = e.currentTarget?.files; if (files && files.length > 0) { setEditImageFile(files[0]); setEditImageError(''); } }} /> )} {editImageMode === 'url' && ( setEditImageUrl(e.currentTarget.value)} error={editImageError} placeholder="https://example.com/image.jpg" /> )} {editImageFile && editImageMode === 'upload' && ( Selected: {editImageFile.name} )} setEditLinkUrl(e.currentTarget.value)} error={editLinkError} placeholder="https://example.com" required /> Save changes Cancel {/* Delete Confirmation Modal */} {deleteError && ( {deleteError} )} Are you sure you want to delete "{artistToDelete?.artistName}"? This action cannot be undone. Delete Cancel ); } export default (): void => render(, document.body);