feat: create auction

This commit is contained in:
bartosz-lipinski 2021-04-16 22:57:29 -05:00
parent 825aca40ef
commit 523f01a719
7 changed files with 212 additions and 240 deletions

View File

@ -1,21 +1,12 @@
import React, { useLayoutEffect, useState } from 'react';
import { Card, Avatar } from 'antd';
import { Card, Avatar, CardProps } from 'antd';
import { MetadataCategory } from '@oyster/common';
import { ArtContent } from './../ArtContent';
import './index.less';
const { Meta } = Card;
export const ArtCard = ({
image,
category,
name,
symbol,
description,
artist,
preview,
small,
}: {
export interface ArtCardProps extends CardProps {
image?: string;
category?: MetadataCategory
name?: string;
@ -23,13 +14,17 @@ export const ArtCard = ({
description?: string;
artist?: string;
preview?: boolean;
small?: boolean
}) => {
small?: boolean;
}
export const ArtCard = (props: ArtCardProps) => {
const { className, small, category, image, name, preview, artist, ...rest } = props;
return (
<Card
hoverable={true}
className={`art-card ${small ? 'small' : ''}`}
cover={<ArtContent category={category} content={image} />}
className={`art-card ${small ? 'small' : ''} ${className}`}
cover={<ArtContent category={category} content={image} preview={preview} />}
{...rest}
>
<Meta
title={`${name}`}

View File

@ -2,11 +2,27 @@ import React, { useMemo } from 'react';
import { Image } from 'antd';
import { MetadataCategory } from '@oyster/common'
export const ArtContent = ({ content, category, className }: { category?: MetadataCategory, content?: string, className?: string }) => {
export const ArtContent = ({
content,
category,
className,
preview
}: {
category?: MetadataCategory,
content?: string,
className?: string,
preview?: boolean,
}) => {
return category === 'video' ?
<video src={content} className={className} playsInline={true} autoPlay={true} controlsList="nodownload" loop={true} /> :
<video src={content}
className={className}
playsInline={true}
autoPlay={true}
controlsList="nodownload"
loop={true} /> :
<Image
src={content}
preview={preview}
wrapperClassName={className}
/>;
}

View File

@ -1,2 +1,3 @@
export * from './useArt';
export * from './useAuctions';
export * from './useUserArts';

View File

@ -0,0 +1,16 @@
import { TokenAccount, useUserAccounts } from '@oyster/common';
import React, { useMemo } from 'react';
import { useMeta } from './../contexts';
export const useUserArts = () => {
const { metadata } = useMeta();
const { userAccounts } = useUserAccounts();
const accountByMint = userAccounts.reduce((prev, acc) => {
prev.set(acc.info.mint.toBase58(), acc);
return prev;
}, new Map<string, TokenAccount>());
const ownedMetadata = metadata.filter(m => accountByMint.has(m.info.mint.toBase58()));
return ownedMetadata;
}

View File

@ -1,20 +1,12 @@
import { ParsedAccount, TokenAccount, useUserAccounts } from '@oyster/common';
import React from 'react';
import { ArtCard } from '../../components/ArtCard';
import { useMeta } from '../../contexts';
import { Row, Col } from 'antd';
import Masonry from 'react-masonry-css'
import { Link } from 'react-router-dom';
import { useUserArts } from '../../hooks';
export const ArtworksView = () => {
const { metadata } = useMeta();
const { userAccounts } = useUserAccounts();
const accountByMint = userAccounts.reduce((prev, acc) => {
prev.set(acc.info.mint.toBase58(), acc);
return prev;
}, new Map<string, TokenAccount>());
const ownedMetadata = metadata.filter(m => accountByMint.has(m.info.mint.toBase58()));
const ownedMetadata = useUserArts();
const breakpointColumnsObj = {
default: 4,
1100: 3,

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Steps,
Row,
@ -12,28 +12,29 @@ import {
Spin,
InputNumber,
Select,
TimePicker,
DatePicker,
} from 'antd';
import { ArtCard } from './../../components/ArtCard';
import { UserSearch, UserValue } from './../../components/UserSearch';
import { Confetti } from './../../components/Confetti';
import './../styles.less';
import { mintNFT } from '../../models';
import {
MAX_METADATA_LEN,
MAX_OWNER_LEN,
MAX_URI_LENGTH,
Metadata,
NameSymbolTuple,
useConnection,
useWallet,
IMetadataExtension,
MetadataCategory,
useConnectionConfig,
Metadata,
ParsedAccount,
} from '@oyster/common';
import { getAssetCostToStore, LAMPORT_MULTIPLIER } from '../../utils/assets';
import { Connection } from '@solana/web3.js';
import { Connection, ParsedAccountData, PublicKey } from '@solana/web3.js';
import { MintLayout } from '@solana/spl-token';
import { useHistory, useParams } from 'react-router-dom';
import { useUserArts } from '../../hooks';
import Masonry from 'react-masonry-css';
const { Step } = Steps;
const { Option } = Select;
@ -46,6 +47,28 @@ export enum AuctionCategory {
Collection
}
export interface AuctionState {
// Min price required for the item to sell
reservationPrice: number;
// listed NFTs
items: ParsedAccount<Metadata>[];
// number of editions for this auction (only applicable to limited edition)
editions?: number;
// date time when auction should start UTC+0
startDate?: Date;
// suggested date time when auction should end UTC+0
endDate?: Date;
// time interval between highest bid and end of the auction
gapTime?: Date
category: AuctionCategory;
}
export const AuctionCreateView = () => {
const connection = useConnection();
const { env } = useConnectionConfig();
@ -56,15 +79,10 @@ export const AuctionCreateView = () => {
const [step, setStep] = useState<number>(0);
const [saving, setSaving] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [attributes, setAttributes] = useState<IMetadataExtension>({
name: '',
symbol: '',
description: '',
externalUrl: '',
image: '',
royalty: 0,
files: [],
category: MetadataCategory.Image,
const [attributes, setAttributes] = useState<AuctionState>({
reservationPrice: 0,
items: [],
category: AuctionCategory.Single,
});
useEffect(() => {
@ -76,19 +94,9 @@ export const AuctionCreateView = () => {
history.push(`/auction/create/${_step.toString()}`)
}
// store files
const mint = async () => {
const metadata = {
...(attributes as any),
image: attributes.files && attributes.files?.[0] && attributes.files[0].name,
files: (attributes?.files || []).map(f => f.name),
}
setSaving(true)
const inte = setInterval(() => setProgress(prog => prog + 1), 600)
// Update progress inside mintNFT
await mintNFT(connection, wallet, env, (attributes?.files || []), metadata)
clearInterval(inte)
}
const createAuction = async () => {
// TODO: ....
};
return (
<>
@ -109,7 +117,7 @@ export const AuctionCreateView = () => {
<Col {...(saving ? { xl: 24 } : { xl: 16 })}>
{step === 0 && (
<CategoryStep
confirm={(category: MetadataCategory) => {
confirm={(category: AuctionCategory) => {
setAttributes({
...attributes,
category,
@ -127,34 +135,27 @@ export const AuctionCreateView = () => {
)}
{step === 2 && (
<InfoStep
<TermsStep
attributes={attributes}
setAttributes={setAttributes}
confirm={() => gotoStep(3)}
/>
)}
{step === 3 && (
<RoyaltiesStep
attributes={attributes}
confirm={() => gotoStep(4)}
setAttributes={setAttributes}
/>
)}
{step === 4 && (
<LaunchStep
<ReviewStep
attributes={attributes}
confirm={() => gotoStep(5)}
connection={connection}
/>
)}
{step === 5 && (
{step === 4 && (
<WaitingStep
mint={mint}
createAuction={createAuction}
progress={progress}
confirm={() => gotoStep(6)}
/>
)}
{step === 6 && (
{step === 5 && (
<Congrats />
)}
{(0 < step && step < 5) && <Button onClick={() => gotoStep(step - 1)}>Back</Button>}
@ -164,7 +165,7 @@ export const AuctionCreateView = () => {
);
};
const CategoryStep = (props: { confirm: (category: MetadataCategory) => void }) => {
const CategoryStep = (props: { confirm: (category: AuctionCategory) => void }) => {
return (
<>
<Row className="call-to-action">
@ -179,7 +180,7 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })
<Button
className="type-btn"
size="large"
onClick={() => props.confirm(MetadataCategory.Image)}
onClick={() => props.confirm(AuctionCategory.Single)}
>
<div>
<div>Single Artwork</div>
@ -191,7 +192,7 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })
<Button
className="type-btn"
size="large"
onClick={() => props.confirm(MetadataCategory.Video)}
onClick={() => props.confirm(AuctionCategory.Limited)}
>
<div>
<div>Limited Edition</div>
@ -203,7 +204,7 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })
<Button
className="type-btn"
size="large"
onClick={() => props.confirm(MetadataCategory.Audio)}
onClick={() => props.confirm(AuctionCategory.Open)}
>
<div>
<div>Open Edition</div>
@ -216,7 +217,7 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })
disabled={true}
className="type-btn"
size="large"
onClick={() => props.confirm(MetadataCategory.Audio)}
onClick={() => props.confirm(AuctionCategory.Collection)}
>
<div>
<div>Collection (Coming Soon)</div>
@ -231,21 +232,27 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })
};
const SelectItemsStep = (props: {
attributes: IMetadataExtension;
setAttributes: (attr: IMetadataExtension) => void;
attributes: AuctionState;
setAttributes: (attr: AuctionState) => void;
confirm: () => void;
}) => {
const [mainFile, setMainFile] = useState<any>()
const [coverFile, setCoverFile] = useState<any>()
const [image, setImage] = useState<string>("")
const items = useUserArts();
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set(props.attributes.items.map(item => item.pubkey.toBase58())));
useEffect(() => {
props.setAttributes({
...props.attributes,
files: []
// TODO: add items
items: items.filter(item => selectedItems.has(item.pubkey.toBase58()))
})
}, [])
}, [selectedItems]);
const breakpointColumnsObj = {
default: 4,
1100: 3,
700: 2,
500: 1
};
return (
<>
@ -256,6 +263,37 @@ const SelectItemsStep = (props: {
</p>
</Row>
<Row className="content-action">
<Masonry
breakpointCols={breakpointColumnsObj}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
{items.map(m => {
const id = m.pubkey.toBase58();
const isSelected = selectedItems.has(id);
const onSelect = () => {
let list = [...selectedItems.keys()];
if (props.attributes.category !== AuctionCategory.Collection) {
list = [];
}
isSelected ?
setSelectedItems(new Set(list.filter(item => item !== id))) :
setSelectedItems(new Set([...list, id]));
};
return <ArtCard key={id}
image={m.info.extended?.image}
category={m.info.extended?.category}
name={m.info?.name}
symbol={m.info.symbol}
preview={false}
onClick={onSelect}
className={isSelected ? 'selected-card' : ''}
/>;
})}
</Masonry>
</Row>
<Row>
<Button
@ -264,23 +302,22 @@ const SelectItemsStep = (props: {
onClick={() => {
props.setAttributes({
...props.attributes,
files: [mainFile, coverFile].filter(f => f),
image,
})
props.confirm()
}}
className="action-btn"
>
Continue to Mint
Continue to Terms
</Button>
</Row>
</>
);
};
const InfoStep = (props: {
attributes: IMetadataExtension;
setAttributes: (attr: IMetadataExtension) => void;
const TermsStep = (props: {
attributes: AuctionState;
setAttributes: (attr: AuctionState) => void;
confirm: () => void;
}) => {
const [creators, setCreators] = useState<Array<UserValue>>([]);
@ -288,76 +325,61 @@ const InfoStep = (props: {
return (
<>
<Row className="call-to-action">
<h2>Describe your creation</h2>
<h2>Specify the terms of your auction</h2>
<p>
Provide detailed description of your creative process to engage with
your audience.
Provide detailed auction parameters such as price, start time, etc.
</p>
</Row>
<Row className="content-action">
<Col xl={12}>
{props.attributes.image && (
<ArtCard
image={props.attributes.image}
category={props.attributes.category}
name={props.attributes.name}
symbol={props.attributes.symbol}
small={true}
/>
)}
</Col>
<Col className="section" xl={12}>
<Col className="section" xl={24}>
<label className="action-field">
<span className="field-title">Title</span>
<span className="field-title">Price Floor (USD)</span>
<Input
autoFocus
className="input"
placeholder="Max 50 characters"
placeholder="Enter reservation price"
allowClear
value={props.attributes.name}
onChange={info =>
props.setAttributes({
...props.attributes,
name: info.target.value,
})
}
/>
<span className="field-info">= 4.84</span>
</label>
<label className="action-field">
<span className="field-title">Symbol</span>
<span className="field-title">Tick Size (USD)</span>
<Input
className="input"
placeholder="Max 10 characters"
placeholder="Enter tick size"
allowClear
value={props.attributes.symbol}
onChange={info =>
props.setAttributes({
...props.attributes,
symbol: info.target.value,
})
}
/>
<span className="field-info">= 4.84</span>
</label>
<label className="action-field">
<span className="field-title">Creators</span>
<UserSearch
setCreators={setCreators}
/>
<span className="field-title">Preview Start Date</span>
<DatePicker className="field-date" size="large" />
<TimePicker className="field-date" size="large" />
</label>
<label className="action-field">
<span className="field-title">Description</span>
<Input.TextArea
className="input textarea"
placeholder="Max 500 characters"
value={props.attributes.description}
onChange={info =>
props.setAttributes({
...props.attributes,
description: info.target.value,
})
}
allowClear
/>
<span className="field-title">When do you want the auction to begin?</span>
<span>Immediately</span>
<span>At a specified data</span>
</label>
<label className="action-field">
<span className="field-title">Auction Start Date</span>
<DatePicker className="field-date" size="large" />
<TimePicker className="field-date" size="large" />
</label>
<label className="action-field">
<span className="field-title">End Start Date</span>
<DatePicker className="field-date" size="large" />
<TimePicker className="field-date" size="large" />
</label>
</Col>
</Row>
@ -368,81 +390,18 @@ const InfoStep = (props: {
onClick={props.confirm}
className="action-btn"
>
Continue to royalties
Continue to Review
</Button>
</Row>
</>
);
};
const RoyaltiesStep = (props: {
attributes: IMetadataExtension;
setAttributes: (attr: IMetadataExtension) => void;
const ReviewStep = (props: {
confirm: () => void;
}) => {
const file = props.attributes.image;
return (
<>
<Row className="call-to-action">
<h2>Set royalties for the creation</h2>
<p>
A royalty is a payment made by the seller of this item to the creator.
It is charged after every successful auction.
</p>
</Row>
<Row className="content-action">
<Col xl={12}>
{file && (
<ArtCard
image={props.attributes.image}
category={props.attributes.category}
name={props.attributes.name}
symbol={props.attributes.symbol}
small={true}
/>
)}
</Col>
<Col className="section" xl={12}>
<label className="action-field">
<span className="field-title">Royalty Percentage</span>
<InputNumber
autoFocus
min={0}
max={100}
placeholder="Between 0 and 100"
onChange={(val: number) => {
props.setAttributes({ ...props.attributes, royalty: val });
}}
className="royalties-input"
/>
</label>
</Col>
</Row>
<Row>
<Button
type="primary"
size="large"
onClick={props.confirm}
className="action-btn"
>
Continue to review
</Button>
</Row>
</>
);
};
const LaunchStep = (props: {
confirm: () => void;
attributes: IMetadataExtension;
attributes: AuctionState;
connection: Connection;
}) => {
const files = props.attributes.files || [];
const metadata = {
...(props.attributes as any),
files: files.map(f => f?.name),
};
const [cost, setCost] = useState(0);
useEffect(() => {
const rentCall = Promise.all([
@ -457,46 +416,27 @@ const LaunchStep = (props: {
)
]);
getAssetCostToStore([
...files,
new File([JSON.stringify(metadata)], 'metadata.json'),
]).then(async lamports => {
const sol = lamports / LAMPORT_MULTIPLIER;
// TODO: add
}, [setCost]);
// TODO: cache this and batch in one call
const [mintRent, metadataRent, nameSymbolRent] = await rentCall;
const uriStr = 'x';
let uriBuilder = '';
for (let i = 0; i < MAX_URI_LENGTH; i++) {
uriBuilder += uriStr;
}
const additionalSol =
(metadataRent + nameSymbolRent + mintRent) / LAMPORT_MULTIPLIER;
// TODO: add fees based on number of transactions and signers
setCost(sol + additionalSol);
});
}, [...files, setCost]);
let item = props.attributes.items?.[0];
return (
<>
<Row className="call-to-action">
<h2>Launch your creation</h2>
<h2>Review and list</h2>
<p>
Provide detailed description of your creative process to engage with
your audience.
Review your listing before publishing.
</p>
</Row>
<Row className="content-action">
<Col xl={12}>
{props.attributes.image && (
{item?.info && (
<ArtCard
image={props.attributes.image}
category={props.attributes.category}
name={props.attributes.name}
symbol={props.attributes.symbol}
image={item.info.extended?.image}
category={item.info.extended?.category}
name={item.info.name}
symbol={item.info.symbol}
small={true}
/>
)}
@ -504,9 +444,8 @@ const LaunchStep = (props: {
<Col className="section" xl={12}>
<Statistic
className="create-statistic"
title="Royalty Percentage"
value={props.attributes.royalty}
suffix="%"
title="Copies"
value={props.attributes.editions === undefined ? 'Unique' : props.attributes.editions }
/>
{cost ? (
<Statistic
@ -527,15 +466,7 @@ const LaunchStep = (props: {
onClick={props.confirm}
className="action-btn"
>
Pay with SOL
</Button>
<Button
disabled={true}
size="large"
onClick={props.confirm}
className="action-btn"
>
Pay with Credit Card
Publish Auction
</Button>
</Row>
</>
@ -543,14 +474,14 @@ const LaunchStep = (props: {
};
const WaitingStep = (props: {
mint: Function,
createAuction: () => Promise<void>,
progress: number,
confirm: Function,
confirm: () => void,
}) => {
useEffect(() => {
const func = async () => {
await props.mint()
await props.createAuction()
props.confirm()
}
func()
@ -563,9 +494,9 @@ const WaitingStep = (props: {
percent={props.progress}
/>
<div className="waiting-title">
Your creation is being uploaded to the decentralized web...
Your creation is being listed with Metaplex...
</div>
<div className="waiting-subtitle">This can take up to 1 minute.</div>
<div className="waiting-subtitle">This can take up to 30 seconds.</div>
</div>
)
}
@ -574,7 +505,7 @@ const Congrats = () => {
return <>
<div style={{ marginTop: 70 }}>
<div className="waiting-title">
Congratulations! Your creation is now live.
Congratulations! Your auction is now live.
</div>
<div className="congrats-button-container">
<Button className="congrats-button"><span>Share it on Twitter</span><span>&gt;</span></Button>

View File

@ -166,6 +166,7 @@
.action-field {
display: flex;
flex-direction: column;
text-align: left;
.field-title {
text-align: left;
@ -185,9 +186,29 @@
margin: 12px 0px;
}
.field-info {
text-align: left;
color: rgba(255, 255, 255, 0.7);
}
margin-bottom: 30px;
}
.field-date {
background: #282828;
border-radius: 8px;
padding: 10px;
border-width: 0px;
margin-bottom: 10px;
}
.selected-card {
border-width: 3px;
border-color: #5870EE !important;
border-style: solid;
}
.royalties-input {
width: 100%;
height: 50px;