diff --git a/apps/web/src/components/Icon/assets/collection.svg b/apps/web/src/components/Icon/assets/collection.svg
new file mode 100644
index 000000000..c0eb1fd5d
--- /dev/null
+++ b/apps/web/src/components/Icon/assets/collection.svg
@@ -0,0 +1,5 @@
+
diff --git a/apps/web/src/components/Icon/assets/pause-template.svg b/apps/web/src/components/Icon/assets/pause-template.svg
new file mode 100644
index 000000000..81dacf1a0
--- /dev/null
+++ b/apps/web/src/components/Icon/assets/pause-template.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/web/src/components/Icon/assets/pause.svg b/apps/web/src/components/Icon/assets/pause.svg
index facad699e..6aed4d57a 100644
--- a/apps/web/src/components/Icon/assets/pause.svg
+++ b/apps/web/src/components/Icon/assets/pause.svg
@@ -1,8 +1,3 @@
diff --git a/apps/web/src/components/Icon/assets/play.svg b/apps/web/src/components/Icon/assets/play.svg
new file mode 100644
index 000000000..f8661a7cd
--- /dev/null
+++ b/apps/web/src/components/Icon/assets/play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/components/Icon/icons.ts b/apps/web/src/components/Icon/icons.ts
index 7a3eb4bd1..89170527b 100644
--- a/apps/web/src/components/Icon/icons.ts
+++ b/apps/web/src/components/Icon/icons.ts
@@ -10,6 +10,7 @@ import ChevronLeft from './assets/chevron-left.svg'
import ChevronRight from './assets/chevron-right.svg'
import ChevronUp from './assets/chevron-up.svg'
import Code from './assets/code.svg'
+import Collection from './assets/collection.svg'
import Copy from './assets/copy.svg'
import Cross16 from './assets/cross-16.svg'
import Cross from './assets/cross.svg'
@@ -26,7 +27,9 @@ import Info16 from './assets/info-16.svg'
import Move from './assets/move.svg'
import NewWindow from './assets/new-window.svg'
import NounsConnect from './assets/nouns-connect.svg'
+import PauseTemplate from './assets/pause-template.svg'
import Pause from './assets/pause.svg'
+import Play from './assets/play.svg'
import Plus from './assets/plus.svg'
import Refresh from './assets/refresh.svg'
import Trash from './assets/trash.svg'
@@ -47,6 +50,7 @@ export const icons = {
chevronRight: ChevronRight,
chevronUp: ChevronUp,
code: Code,
+ collection: Collection,
copy: Copy,
cross: Cross,
'cross-16': Cross16,
@@ -64,6 +68,8 @@ export const icons = {
newWindow: NewWindow,
nounsConnect: NounsConnect,
pause: Pause,
+ pauseTemplate: PauseTemplate,
+ play: Play,
plus: Plus,
refresh: Refresh,
trash: Trash,
diff --git a/apps/web/src/components/MediaPreview/Audio.tsx b/apps/web/src/components/MediaPreview/Audio.tsx
new file mode 100644
index 000000000..a57bb72b3
--- /dev/null
+++ b/apps/web/src/components/MediaPreview/Audio.tsx
@@ -0,0 +1,69 @@
+import { Box } from '@zoralabs/zord'
+import NextImage from 'next/image'
+import { useCallback, useRef, useState } from 'react'
+
+import { Icon } from '../Icon'
+
+export interface AudioProps {
+ src: string
+ cover?: string
+}
+
+export const Audio: React.FC = ({ src, cover }) => {
+ const [playing, setPlaying] = useState(false)
+ const audioRef = useRef(null)
+
+ const togglePlay = useCallback(async () => {
+ if (!audioRef.current) return
+ if (playing) audioRef.current.pause()
+ else audioRef.current.play()
+ }, [audioRef, playing])
+
+ return (
+
+ {!cover ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/MediaPreview/Image.tsx b/apps/web/src/components/MediaPreview/Image.tsx
new file mode 100644
index 000000000..cb6be49ab
--- /dev/null
+++ b/apps/web/src/components/MediaPreview/Image.tsx
@@ -0,0 +1,23 @@
+import NextImage from 'next/image'
+
+export interface ImageProps {
+ src: string
+}
+
+export const Image: React.FC = ({ src }) => {
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/MediaPreview/MediaPreview.tsx b/apps/web/src/components/MediaPreview/MediaPreview.tsx
new file mode 100644
index 000000000..a2bd7f97f
--- /dev/null
+++ b/apps/web/src/components/MediaPreview/MediaPreview.tsx
@@ -0,0 +1,36 @@
+import { Box } from '@zoralabs/zord'
+import { getFetchableUrl } from 'ipfs-service'
+import { useMemo } from 'react'
+
+import { Audio } from './Audio'
+import { Image } from './Image'
+import { Video } from './Video'
+
+export interface MediaPreviewProps {
+ mediaUrl: string
+ mediaType?: string
+ coverUrl?: string
+}
+
+export const MediaPreview: React.FC = ({
+ mediaType,
+ mediaUrl,
+ coverUrl,
+}) => {
+ const fetchableMediaURL = useMemo(() => getFetchableUrl(mediaUrl) || '', [mediaUrl])
+ const fetchableCoverURL = useMemo(() => getFetchableUrl(coverUrl) || '', [coverUrl])
+
+ if (fetchableMediaURL && mediaType?.startsWith('image')) {
+ return
+ }
+
+ if (fetchableMediaURL && mediaType?.startsWith('video')) {
+ return
+ }
+
+ if (fetchableMediaURL && mediaType?.startsWith('audio')) {
+ return
+ }
+
+ return
+}
diff --git a/apps/web/src/components/MediaPreview/Video.tsx b/apps/web/src/components/MediaPreview/Video.tsx
new file mode 100644
index 000000000..a6d603f25
--- /dev/null
+++ b/apps/web/src/components/MediaPreview/Video.tsx
@@ -0,0 +1,21 @@
+export interface VideoProps {
+ src: string
+}
+
+export const Video: React.FC = ({ src }) => {
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.css.ts b/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.css.ts
new file mode 100644
index 000000000..7e24f3958
--- /dev/null
+++ b/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.css.ts
@@ -0,0 +1,23 @@
+import { style } from '@vanilla-extract/css'
+
+export const defaultUploadStyle = style({
+ display: 'none',
+})
+
+export const uploadErrorBox = style({
+ color: '#ff0015',
+ boxSizing: 'border-box',
+})
+
+export const singleMediaUploadWrapper = style({
+ height: 64,
+ width: '100%',
+ borderRadius: 15,
+ background: '#F2F2F2',
+ overflow: 'hidden',
+ selectors: {
+ '&:hover': {
+ cursor: 'pointer',
+ },
+ },
+})
diff --git a/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.tsx b/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.tsx
new file mode 100644
index 000000000..6a6d1dc04
--- /dev/null
+++ b/apps/web/src/components/SingleMediaUpload/SingleMediaUpload.tsx
@@ -0,0 +1,173 @@
+import { Box, Flex, Stack, Text } from '@zoralabs/zord'
+import { FormikProps } from 'formik'
+import { normalizeIPFSUrl, uploadFile } from 'ipfs-service'
+import React, { ReactElement, useEffect, useState } from 'react'
+
+import { Spinner } from 'src/components/Spinner'
+import { defaultInputLabelStyle } from 'src/modules/create-proposal/components/TransactionForm/CustomTransaction/CustomTransaction.css'
+
+import {
+ defaultUploadStyle,
+ singleMediaUploadWrapper,
+ uploadErrorBox,
+} from './SingleMediaUpload.css'
+
+interface SingleImageUploadProps {
+ formik: FormikProps
+ id: string
+ inputLabel: string | ReactElement
+ value: string
+ onUploadStart?: (value: File) => void
+ onUploadSettled?: () => void
+}
+
+const SingleMediaUpload: React.FC = ({
+ id,
+ formik,
+ inputLabel,
+ onUploadStart,
+ onUploadSettled,
+ value,
+}) => {
+ const acceptableMIME = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ 'image/gif',
+ 'video/mp4',
+ 'video/quicktime',
+ 'audio/mpeg',
+ 'audio/wav',
+ ]
+
+ const [isMounted, setIsMounted] = useState(false)
+ const [uploadMediaError, setUploadMediaError] = React.useState()
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [fileName, setFileName] = React.useState()
+ const [progress, setProgress] = React.useState(0)
+
+ useEffect(() => {
+ setIsMounted(true)
+ }, [])
+
+ const truncate = (value: string) => {
+ return value.length > 40 ? `${value.substring(0, 40)}...` : value
+ }
+
+ const handleFileUpload = React.useCallback(
+ async (_input: FileList | null) => {
+ if (!_input) return
+ const input = _input[0]
+
+ setUploadMediaError(false)
+
+ if (input?.type?.length && !acceptableMIME.includes(input.type)) {
+ setUploadMediaError({
+ message: `Sorry, ${input.type} is an unsupported file type`,
+ })
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setFileName(input.name)
+
+ onUploadStart?.(input)
+
+ const { cid } = await uploadFile(_input[0], {
+ cache: true,
+ onProgress: setProgress,
+ })
+
+ formik.setFieldValue(id, normalizeIPFSUrl(cid))
+ setIsUploading(false)
+ setUploadMediaError(null)
+ } catch (err: any) {
+ setIsUploading(false)
+ setUploadMediaError({
+ ...err,
+ message: `Sorry, there was an error with our file uploading service. ${err?.message}`,
+ })
+ } finally {
+ onUploadSettled?.()
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ )
+
+ return (
+
+
+
+
+ {!isUploading && isMounted && !value && (
+
+
+ None selected
+
+
+ Select
+
+
+ )}
+
+ {isUploading && fileName && (
+
+
+ {truncate(fileName)}
+
+
+
+ {`${Math.round(progress)}% uploaded`}
+
+
+
+
+ )}
+
+ {!isUploading && value && (
+
+
+ {truncate(value)}
+
+
+ Replace
+
+
+ )}
+
+ {
+ handleFileUpload(event.currentTarget.files)
+ }}
+ />
+
+
+ {uploadMediaError && (
+
+
+ {uploadMediaError.message}
+
+
+ )}
+
+
+ )
+}
+
+export default SingleMediaUpload
diff --git a/apps/web/src/constants/addresses.ts b/apps/web/src/constants/addresses.ts
index 5a867abb7..03f98168f 100644
--- a/apps/web/src/constants/addresses.ts
+++ b/apps/web/src/constants/addresses.ts
@@ -17,4 +17,9 @@ export const PUBLIC_NOUNS_ADDRESS = {
5: '0x0BC3807Ec262cB779b38D65b38158acC3bfedE10',
}[process.env.NEXT_PUBLIC_CHAIN_ID || 1] as AddressType
+export const PUBLIC_ZORA_NFT_CREATOR = {
+ 1: '0xF74B146ce44CC162b601deC3BE331784DB111DC1',
+ 5: '0xb9583D05Ba9ba8f7F14CCEe3Da10D2bc0A72f519',
+}[process.env.NEXT_PUBLIC_CHAIN_ID || 1] as AddressType
+
export const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'
diff --git a/apps/web/src/data/contract/abis/ZoraNFTCreator.ts b/apps/web/src/data/contract/abis/ZoraNFTCreator.ts
new file mode 100644
index 000000000..78972c7cd
--- /dev/null
+++ b/apps/web/src/data/contract/abis/ZoraNFTCreator.ts
@@ -0,0 +1,600 @@
+export const zoraNFTCreatorAbi = [
+ {
+ inputs: [
+ {
+ internalType: 'address',
+ name: '_implementation',
+ type: 'address',
+ },
+ {
+ internalType: 'contract EditionMetadataRenderer',
+ name: '_editionMetadataRenderer',
+ type: 'address',
+ },
+ {
+ internalType: 'contract DropMetadataRenderer',
+ name: '_dropMetadataRenderer',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'nonpayable',
+ type: 'constructor',
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: false,
+ internalType: 'address',
+ name: 'previousAdmin',
+ type: 'address',
+ },
+ {
+ indexed: false,
+ internalType: 'address',
+ name: 'newAdmin',
+ type: 'address',
+ },
+ ],
+ name: 'AdminChanged',
+ type: 'event',
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'beacon',
+ type: 'address',
+ },
+ ],
+ name: 'BeaconUpgraded',
+ type: 'event',
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'creator',
+ type: 'address',
+ },
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'editionContractAddress',
+ type: 'address',
+ },
+ {
+ indexed: false,
+ internalType: 'uint256',
+ name: 'editionSize',
+ type: 'uint256',
+ },
+ ],
+ name: 'CreatedDrop',
+ type: 'event',
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'previousOwner',
+ type: 'address',
+ },
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'newOwner',
+ type: 'address',
+ },
+ ],
+ name: 'OwnershipTransferred',
+ type: 'event',
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: 'address',
+ name: 'implementation',
+ type: 'address',
+ },
+ ],
+ name: 'Upgraded',
+ type: 'event',
+ },
+ {
+ inputs: [],
+ name: 'contractVersion',
+ outputs: [
+ {
+ internalType: 'uint32',
+ name: '',
+ type: 'uint32',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'string',
+ name: 'name',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'symbol',
+ type: 'string',
+ },
+ {
+ internalType: 'address',
+ name: 'defaultAdmin',
+ type: 'address',
+ },
+ {
+ internalType: 'uint64',
+ name: 'editionSize',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint16',
+ name: 'royaltyBPS',
+ type: 'uint16',
+ },
+ {
+ internalType: 'address payable',
+ name: 'fundsRecipient',
+ type: 'address',
+ },
+ {
+ internalType: 'bytes[]',
+ name: 'setupCalls',
+ type: 'bytes[]',
+ },
+ {
+ internalType: 'contract IMetadataRenderer',
+ name: 'metadataRenderer',
+ type: 'address',
+ },
+ {
+ internalType: 'bytes',
+ name: 'metadataInitializer',
+ type: 'bytes',
+ },
+ ],
+ name: 'createAndConfigureDrop',
+ outputs: [
+ {
+ internalType: 'address payable',
+ name: 'newDropAddress',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'string',
+ name: 'name',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'symbol',
+ type: 'string',
+ },
+ {
+ internalType: 'address',
+ name: 'defaultAdmin',
+ type: 'address',
+ },
+ {
+ internalType: 'uint64',
+ name: 'editionSize',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint16',
+ name: 'royaltyBPS',
+ type: 'uint16',
+ },
+ {
+ internalType: 'address payable',
+ name: 'fundsRecipient',
+ type: 'address',
+ },
+ {
+ components: [
+ {
+ internalType: 'uint104',
+ name: 'publicSalePrice',
+ type: 'uint104',
+ },
+ {
+ internalType: 'uint32',
+ name: 'maxSalePurchasePerAddress',
+ type: 'uint32',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'bytes32',
+ name: 'presaleMerkleRoot',
+ type: 'bytes32',
+ },
+ ],
+ internalType: 'struct IERC721Drop.SalesConfiguration',
+ name: 'saleConfig',
+ type: 'tuple',
+ },
+ {
+ internalType: 'string',
+ name: 'metadataURIBase',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'metadataContractURI',
+ type: 'string',
+ },
+ ],
+ name: 'createDrop',
+ outputs: [
+ {
+ internalType: 'address',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'string',
+ name: 'name',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'symbol',
+ type: 'string',
+ },
+ {
+ internalType: 'uint64',
+ name: 'editionSize',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint16',
+ name: 'royaltyBPS',
+ type: 'uint16',
+ },
+ {
+ internalType: 'address payable',
+ name: 'fundsRecipient',
+ type: 'address',
+ },
+ {
+ internalType: 'address',
+ name: 'defaultAdmin',
+ type: 'address',
+ },
+ {
+ components: [
+ {
+ internalType: 'uint104',
+ name: 'publicSalePrice',
+ type: 'uint104',
+ },
+ {
+ internalType: 'uint32',
+ name: 'maxSalePurchasePerAddress',
+ type: 'uint32',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'bytes32',
+ name: 'presaleMerkleRoot',
+ type: 'bytes32',
+ },
+ ],
+ internalType: 'struct IERC721Drop.SalesConfiguration',
+ name: 'saleConfig',
+ type: 'tuple',
+ },
+ {
+ internalType: 'string',
+ name: 'description',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'animationURI',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'imageURI',
+ type: 'string',
+ },
+ ],
+ name: 'createEdition',
+ outputs: [
+ {
+ internalType: 'address',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'dropMetadataRenderer',
+ outputs: [
+ {
+ internalType: 'contract DropMetadataRenderer',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'editionMetadataRenderer',
+ outputs: [
+ {
+ internalType: 'contract EditionMetadataRenderer',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'implementation',
+ outputs: [
+ {
+ internalType: 'address',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'initialize',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'owner',
+ outputs: [
+ {
+ internalType: 'address',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'proxiableUUID',
+ outputs: [
+ {
+ internalType: 'bytes32',
+ name: '',
+ type: 'bytes32',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'renounceOwnership',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'string',
+ name: 'name',
+ type: 'string',
+ },
+ {
+ internalType: 'string',
+ name: 'symbol',
+ type: 'string',
+ },
+ {
+ internalType: 'address',
+ name: 'defaultAdmin',
+ type: 'address',
+ },
+ {
+ internalType: 'uint64',
+ name: 'editionSize',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint16',
+ name: 'royaltyBPS',
+ type: 'uint16',
+ },
+ {
+ internalType: 'address payable',
+ name: 'fundsRecipient',
+ type: 'address',
+ },
+ {
+ components: [
+ {
+ internalType: 'uint104',
+ name: 'publicSalePrice',
+ type: 'uint104',
+ },
+ {
+ internalType: 'uint32',
+ name: 'maxSalePurchasePerAddress',
+ type: 'uint32',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'publicSaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleStart',
+ type: 'uint64',
+ },
+ {
+ internalType: 'uint64',
+ name: 'presaleEnd',
+ type: 'uint64',
+ },
+ {
+ internalType: 'bytes32',
+ name: 'presaleMerkleRoot',
+ type: 'bytes32',
+ },
+ ],
+ internalType: 'struct IERC721Drop.SalesConfiguration',
+ name: 'saleConfig',
+ type: 'tuple',
+ },
+ {
+ internalType: 'contract IMetadataRenderer',
+ name: 'metadataRenderer',
+ type: 'address',
+ },
+ {
+ internalType: 'bytes',
+ name: 'metadataInitializer',
+ type: 'bytes',
+ },
+ ],
+ name: 'setupDropsContract',
+ outputs: [
+ {
+ internalType: 'address',
+ name: '',
+ type: 'address',
+ },
+ ],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'address',
+ name: 'newOwner',
+ type: 'address',
+ },
+ ],
+ name: 'transferOwnership',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'address',
+ name: 'newImplementation',
+ type: 'address',
+ },
+ ],
+ name: 'upgradeTo',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [
+ {
+ internalType: 'address',
+ name: 'newImplementation',
+ type: 'address',
+ },
+ {
+ internalType: 'bytes',
+ name: 'data',
+ type: 'bytes',
+ },
+ ],
+ name: 'upgradeToAndCall',
+ outputs: [],
+ stateMutability: 'payable',
+ type: 'function',
+ },
+] as const
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.css.ts b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.css.ts
new file mode 100644
index 000000000..5da39bf15
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.css.ts
@@ -0,0 +1,14 @@
+import { style } from '@vanilla-extract/css'
+import { atoms } from '@zoralabs/zord'
+
+export const defaultInputLabelStyle = style([
+ atoms({
+ display: 'inline-flex',
+ fontSize: 16,
+ mb: 'x4',
+ }),
+ {
+ whiteSpace: 'nowrap',
+ fontWeight: '700',
+ },
+])
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.tsx b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.tsx
new file mode 100644
index 000000000..c36c8dd5f
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/Droposal.tsx
@@ -0,0 +1,95 @@
+import { Stack } from '@zoralabs/zord'
+import { BigNumber, ethers } from 'ethers'
+import { FormikHelpers } from 'formik'
+import { useContract } from 'wagmi'
+
+import { PUBLIC_ZORA_NFT_CREATOR } from 'src/constants/addresses'
+import { zoraNFTCreatorAbi } from 'src/data/contract/abis/ZoraNFTCreator'
+import { TransactionType } from 'src/modules/create-proposal/constants'
+import { useProposalStore } from 'src/modules/create-proposal/stores'
+import { AddressType } from 'src/typings'
+
+import { DroposalForm } from './DroposalForm'
+import { DroposalFormValues } from './DroposalForm.schema'
+
+export const Droposal: React.FC = () => {
+ const addTransaction = useProposalStore((state) => state.addTransaction)
+ const zoraNFTCreatorContract = useContract({
+ abi: zoraNFTCreatorAbi,
+ address: PUBLIC_ZORA_NFT_CREATOR,
+ })
+
+ const handleDroposalTransaction = (
+ values: DroposalFormValues,
+ actions: FormikHelpers
+ ) => {
+ const {
+ name,
+ symbol,
+ maxSupply: editionSize,
+ royaltyPercentage,
+ fundsRecipient,
+ defaultAdmin,
+ pricePerMint: publicSalePrice,
+ maxPerAddress: maxSalePurchasePerAddress,
+ publicSaleStart,
+ publicSaleEnd,
+ description,
+ mediaUrl,
+ mediaType,
+ coverUrl,
+ } = values
+
+ const royaltyBPS = royaltyPercentage * 100
+ const salesConfig = [
+ ethers.utils.parseEther((publicSalePrice || 0).toString()),
+ maxSalePurchasePerAddress,
+ BigNumber.from(Math.floor(new Date(publicSaleStart).getTime() / 1000)),
+ BigNumber.from(Math.floor(new Date(publicSaleEnd).getTime() / 1000)),
+ 0, // presaleStart
+ 0, // presaleEnd
+ ethers.constants.HashZero, // presaleMerkleRoot
+ ]
+ const animationUri = mediaType?.startsWith('image') ? '' : mediaUrl
+ const imageUri = mediaType?.startsWith('image') ? mediaUrl : coverUrl
+
+ const createEdition = {
+ target: PUBLIC_ZORA_NFT_CREATOR as AddressType,
+ functionSignature: 'createEdition()',
+ calldata:
+ zoraNFTCreatorContract?.interface.encodeFunctionData(
+ 'createEdition(string,string,uint64,uint16,address,address,(uint104,uint32,uint64,uint64,uint64,uint64,bytes32),string,string,string)',
+ [
+ name,
+ symbol,
+ editionSize,
+ royaltyBPS,
+ fundsRecipient,
+ defaultAdmin,
+ salesConfig,
+ description,
+ animationUri,
+ imageUri,
+ ]
+ ) || '',
+ value: '',
+ }
+
+ addTransaction({
+ type: TransactionType.DROPOSAL,
+ summary: 'Create a droposal',
+ transactions: [createEdition],
+ })
+
+ actions.resetForm()
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.schema.ts b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.schema.ts
new file mode 100644
index 000000000..a5d0a90dc
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.schema.ts
@@ -0,0 +1,53 @@
+import * as yup from 'yup'
+
+import { deboucedValidateAddress } from 'src/modules/create-dao/components/AllocationForm/AllocationForm.schema'
+
+export interface DroposalFormValues {
+ name: string
+ symbol: string
+ description: string
+ mediaUrl: string
+ mediaType?: string
+ coverUrl: string
+ pricePerMint: number
+ maxPerAddress: number
+ maxSupply: number
+ royaltyPercentage: number
+ fundsRecipient: string
+ defaultAdmin: string
+ publicSaleStart: string
+ publicSaleEnd: string
+}
+
+const droposalFormSchema = yup.object({
+ name: yup.string().required('*'),
+ symbol: yup.string().required('*'),
+ description: yup.string().required('*'),
+ mediaUrl: yup.string().required('*'),
+ mediaType: yup.string(),
+ coverUrl: yup.string(),
+ pricePerMint: yup.number().required('*'),
+ maxPerAddress: yup.number().integer('Must be whole number'),
+ maxSupply: yup.number().integer('Must be whole number'),
+ royaltyPercentage: yup.number().required('*'),
+ defaultAdmin: yup
+ .string()
+ .required('*')
+ .test('isValidAddress', 'invalid address', deboucedValidateAddress),
+ fundsRecipient: yup
+ .string()
+ .required('*')
+ .test('isValidAddress', 'invalid address', deboucedValidateAddress),
+ publicSaleStart: yup.string().required('*'),
+ publicSaleEnd: yup
+ .string()
+ .required('*')
+ .test('isDateInFuture', 'Must be in future', (value: string | undefined) => {
+ if (!value) return false
+ const date = new Date(value)
+ const now = new Date()
+ return date > now
+ }),
+})
+
+export default droposalFormSchema
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.tsx b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.tsx
new file mode 100644
index 000000000..4fdde537e
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalForm.tsx
@@ -0,0 +1,379 @@
+import { Box, Button, Flex, Text } from '@zoralabs/zord'
+import { Form, Formik, FormikHelpers } from 'formik'
+import { useCallback, useState } from 'react'
+import { useAccount } from 'wagmi'
+
+import SmartInput from 'src/components/Fields/SmartInput'
+import TextArea from 'src/components/Fields/TextArea'
+import { defaultHelperTextStyle } from 'src/components/Fields/styles.css'
+import { DATE, NUMBER, TEXT } from 'src/components/Fields/types'
+import SingleMediaUpload from 'src/components/SingleMediaUpload/SingleMediaUpload'
+import { DropdownSelect } from 'src/modules/create-proposal'
+import { useDaoStore } from 'src/modules/dao'
+import { useLayoutStore } from 'src/stores'
+
+import { defaultInputLabelStyle } from './Droposal.css'
+import droposalFormSchema, { DroposalFormValues } from './DroposalForm.schema'
+import { DroposalPreview } from './DroposalPreview'
+
+export interface AirdropFormProps {
+ onSubmit?: (
+ values: DroposalFormValues,
+ actions: FormikHelpers
+ ) => void
+ disabled?: boolean
+}
+
+const editionSizeOptions = [
+ { label: 'Fixed', value: 'fixed' },
+ { label: 'Open edition', value: 'open' },
+]
+
+export const DroposalForm: React.FC = ({ onSubmit, disabled }) => {
+ const [editionType, setEditionType] = useState('fixed')
+ const [isIPFSUploading, setIsIPFSUploading] = useState(false)
+ const { address: user } = useAccount()
+ const { treasury } = useDaoStore((x) => x.addresses)
+ const isMobile = useLayoutStore((x) => x.isMobile)
+
+ const initialValues: DroposalFormValues = {
+ name: '',
+ symbol: '',
+ description: '',
+ mediaUrl: '',
+ coverUrl: '',
+ fundsRecipient: treasury || '',
+ defaultAdmin: user || '',
+ publicSaleStart: '',
+ publicSaleEnd: '',
+ royaltyPercentage: 5,
+ pricePerMint: 0,
+ maxPerAddress: 1,
+ maxSupply: 10,
+ }
+
+ const handleSubmit = useCallback(
+ (values: DroposalFormValues, actions: FormikHelpers) => {
+ onSubmit?.(values, actions)
+ },
+ [onSubmit]
+ )
+
+ return (
+
+
+ {(formik) => {
+ const handleMediaUploadStart = (media: File) => {
+ setIsIPFSUploading(true)
+ formik.setFieldValue('mediaType', media.type)
+ }
+
+ const handleEditionTypeChanged = (value: string) => {
+ value === 'open'
+ ? formik.setFieldValue('editionSize', 0)
+ : formik.setFieldValue('editionSize', undefined)
+ setEditionType(value)
+ }
+
+ const showCover = formik.values['mediaType']
+ ? !formik.values['mediaType']?.startsWith('image')
+ : false
+
+ return (
+ <>
+ {!isMobile && }
+
+ This droposal uses the ZORA 721 Contract.{' '}
+
+ Lean more
+
+
+
+
+
+
+
+
+
+
+ setIsIPFSUploading(false)}
+ />
+
+ {showCover && (
+ setIsIPFSUploading(true)}
+ onUploadSettled={() => setIsIPFSUploading(false)}
+ />
+ )}
+
+
+
+
+ Zora charges a small flat fee 0.000777 ETH per NFT minted to
+ collectors.{' '}
+
+ Learn more
+
+
+
+
+
+
+
+ {editionType === 'fixed' ? (
+
+ ) : (
+
+
+
+ Unlimited
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }}
+
+
+ )
+}
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalPreview.tsx b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalPreview.tsx
new file mode 100644
index 000000000..7b5bbba27
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/DroposalPreview.tsx
@@ -0,0 +1,70 @@
+import { Box, Flex, Text } from '@zoralabs/zord'
+import { FormikProps } from 'formik'
+
+import { MediaPreview } from 'src/components/MediaPreview/MediaPreview'
+
+import { DroposalFormValues } from './DroposalForm.schema'
+
+interface DroposalPreviewProps {
+ formik: FormikProps
+}
+
+export const DroposalPreview: React.FC = ({ formik }) => {
+ const {
+ mediaUrl,
+ coverUrl,
+ mediaType,
+ symbol,
+ name,
+ description,
+ pricePerMint,
+ maxSupply,
+ } = formik.values
+ return (
+
+
+
+
+
+
+ {name || 'Collection name'}
+
+
+
+ ${symbol || 'SYMBOL'}
+
+
+ EDITION
+
+
+
+ {description || 'description'}
+
+
+
+
+ EDITION PRICE
+
+
+ {pricePerMint || '0.00'} ETH
+
+
+
+
+ TOTAL SUPPLY
+
+
+ {maxSupply || 'OPEN'}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/index.ts b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/index.ts
new file mode 100644
index 000000000..7508606e4
--- /dev/null
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/Droposal/index.ts
@@ -0,0 +1 @@
+export * from './Droposal'
diff --git a/apps/web/src/modules/create-proposal/components/TransactionForm/TransactionForm.tsx b/apps/web/src/modules/create-proposal/components/TransactionForm/TransactionForm.tsx
index 37eeac5a7..b1f99a1d9 100644
--- a/apps/web/src/modules/create-proposal/components/TransactionForm/TransactionForm.tsx
+++ b/apps/web/src/modules/create-proposal/components/TransactionForm/TransactionForm.tsx
@@ -4,6 +4,7 @@ import { TransactionType } from 'src/modules/create-proposal/constants'
import { Airdrop } from './Airdrop'
import { CustomTransaction } from './CustomTransaction'
+import { Droposal } from './Droposal'
import { PauseAuctions } from './PauseAuctions'
import { SendEth } from './SendEth'
@@ -16,6 +17,7 @@ export type TransactionFormType = typeof TRANSACTION_FORM_OPTIONS[number]
export const TRANSACTION_FORM_OPTIONS = [
TransactionType.SEND_ETH,
TransactionType.AIRDROP,
+ TransactionType.DROPOSAL,
TransactionType.PAUSE_AUCTIONS,
TransactionType.CUSTOM,
] as const
@@ -24,6 +26,7 @@ export const TransactionForm = ({ type }: TransactionFormProps) => {
const FORMS: { [key in TransactionFormType]: ReactNode } = {
[TransactionType.CUSTOM]: ,
[TransactionType.AIRDROP]: ,
+ [TransactionType.DROPOSAL]: ,
[TransactionType.SEND_ETH]: ,
[TransactionType.PAUSE_AUCTIONS]: ,
}
diff --git a/apps/web/src/modules/create-proposal/components/TwoColumnLayout.tsx b/apps/web/src/modules/create-proposal/components/TwoColumnLayout.tsx
index 3f6b75f9e..47edd9d88 100644
--- a/apps/web/src/modules/create-proposal/components/TwoColumnLayout.tsx
+++ b/apps/web/src/modules/create-proposal/components/TwoColumnLayout.tsx
@@ -13,7 +13,7 @@ export const TwoColumnLayout: FC = ({
rightColumn,
}) => {
return (
-
+
{leftColumn && {leftColumn}}
diff --git a/apps/web/src/modules/create-proposal/constants/transactionType.tsx b/apps/web/src/modules/create-proposal/constants/transactionType.tsx
index 20717e0be..69cde914c 100644
--- a/apps/web/src/modules/create-proposal/constants/transactionType.tsx
+++ b/apps/web/src/modules/create-proposal/constants/transactionType.tsx
@@ -5,6 +5,7 @@ import { IconType } from 'src/components/Icon/icons'
export enum TransactionType {
SEND_ETH = 'send-eth',
AIRDROP = 'airdrop',
+ DROPOSAL = 'droposal',
CUSTOM = 'custom',
UPGRADE = 'upgrade',
PAUSE_AUCTIONS = 'pause-auctions',
@@ -36,6 +37,12 @@ export const TRANSACTION_TYPES = {
icon: 'airdrop',
iconBackdrop: 'rgba(28, 182, 135, 0.1)',
},
+ [TransactionType.DROPOSAL]: {
+ title: 'Droposal: Single edition',
+ subTitle: 'Create a droposal for a Single-edition ERC721 collection',
+ icon: 'collection',
+ iconBackdrop: 'rgba(0, 163, 255, 0.1)',
+ },
[TransactionType.UPGRADE]: {
title: 'Upgrade Proposal',
subTitle: 'Create a proposal to upgrade',
@@ -45,7 +52,7 @@ export const TRANSACTION_TYPES = {
[TransactionType.PAUSE_AUCTIONS]: {
title: 'Pause Auctions',
subTitle: 'Create a proposal to pause auctions',
- icon: 'pause',
+ icon: 'pauseTemplate',
iconBackdrop: 'rgba(236, 113, 75, 0.1)',
},
[TransactionType.CUSTOM]: {