From 33f3fe708d5aa0962d1e76cd2104989985a1bf5d Mon Sep 17 00:00:00 2001 From: WRadoslaw <92513933+WRadoslaw@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:03:06 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=98=AE=E2=80=8D=F0=9F=92=A8=20Start?= =?UTF-8?q?=20revenue=20share=20modal=20(#4869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add start revenue share modal * CR fixes * CR fixes v2 * Lint --- .../StartRevenueShareModal.tsx | 235 ++++++++++++++++++ .../AuctionDatePicker/AuctionDatePicker.tsx | 1 + .../studio/CrtDashboard/CrtDashboard.tsx | 22 +- 3 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 packages/atlas/src/components/_crt/StartRevenueShareModal/StartRevenueShareModal.tsx diff --git a/packages/atlas/src/components/_crt/StartRevenueShareModal/StartRevenueShareModal.tsx b/packages/atlas/src/components/_crt/StartRevenueShareModal/StartRevenueShareModal.tsx new file mode 100644 index 0000000000..8d2266891a --- /dev/null +++ b/packages/atlas/src/components/_crt/StartRevenueShareModal/StartRevenueShareModal.tsx @@ -0,0 +1,235 @@ +import styled from '@emotion/styled' +import { useCallback, useMemo } from 'react' +import { Controller, useForm } from 'react-hook-form' + +import { FlexBox } from '@/components/FlexBox/FlexBox' +import { JoyTokenIcon } from '@/components/JoyTokenIcon' +import { NumberFormat } from '@/components/NumberFormat' +import { Text } from '@/components/Text' +import { TextButton } from '@/components/_buttons/Button' +import { AuctionDatePicker, AuctionDatePickerProps } from '@/components/_inputs/AuctionDatePicker' +import { FormField } from '@/components/_inputs/FormField' +import { TokenInput } from '@/components/_inputs/TokenInput' +import { DetailsContent } from '@/components/_nft/NftTile' +import { DialogModal } from '@/components/_overlays/DialogModal' +import { useMediaMatch } from '@/hooks/useMediaMatch' +import { pluralizeNoun } from '@/utils/misc' + +export type StartRevenueShareProps = { + tokenId: string + onClose: () => void + show: boolean +} + +const getTokenDetails = (_: string) => ({ + title: 'JBC', + pricePerUnit: 1000, + tokensOnSale: 67773, + userBalance: 100000, + patronageRate: 0.8, +}) + +const datePickerItemsFactory = (days: number[]) => + days.map((value) => ({ + name: pluralizeNoun(value, 'day'), + value: { + type: 'duration' as const, + durationDays: value, + }, + })) + +const endDateItems = datePickerItemsFactory([7, 14, 30]) + +export const StartRevenueShare = ({ tokenId, onClose, show }: StartRevenueShareProps) => { + const smMatch = useMediaMatch('sm') + const { patronageRate, userBalance } = getTokenDetails(tokenId) + + const form = useForm<{ + tokens: number | null + startDate: AuctionDatePickerProps['value'] | null + endDate: AuctionDatePickerProps['value'] | null + }>() + const { trigger, control, watch } = form + const [startDate, endDate, tokens] = watch(['startDate', 'endDate', 'tokens']) + + const details = useMemo( + () => [ + { + title: 'You will receive', + content: ( + + + + ({Math.round(patronageRate * 100)}%) + + + ), + }, + { + title: 'Your holders will receive', + content: ( + + + + ( {Math.round((1 - patronageRate) * 100)}%) + + + ), + }, + ], + [patronageRate, tokens] + ) + + const selectDurationToDate = useCallback((value: AuctionDatePickerProps['value'], base?: Date) => { + if (value?.type === 'date') { + return value.date + } + + if (value?.type === 'duration') { + const now = base ? new Date(base.getTime()) : new Date() + now.setDate(now.getDate() + value.durationDays) + return now + } + return undefined + }, []) + + return ( + + + + } + withDenomination + /> + + ( + + + + $0.00 + + Max + + } + /> + + )} + /> + + + {details.map((row) => ( + + + + {row.title} + + + {row.content} + + ))} + + + + ( + + + + { + onChange(value) + trigger('startDate') + }} + value={value} + /> + + + + )} + /> + ( + + + + { + onChange(value) + trigger('endDate') + }} + value={value} + /> + + + + )} + /> + + + + ) +} + +const OuterBox = styled.div` + position: relative; + height: 50px; +` + +const InnerBox = styled.div` + position: absolute; + inset: 0; +` diff --git a/packages/atlas/src/components/_inputs/AuctionDatePicker/AuctionDatePicker.tsx b/packages/atlas/src/components/_inputs/AuctionDatePicker/AuctionDatePicker.tsx index 6cb9f41368..c3c5e3175c 100644 --- a/packages/atlas/src/components/_inputs/AuctionDatePicker/AuctionDatePicker.tsx +++ b/packages/atlas/src/components/_inputs/AuctionDatePicker/AuctionDatePicker.tsx @@ -136,6 +136,7 @@ export const AuctionDatePicker: FC = ({ offset={[0, 8]} ref={popOverRef} triggerMode="manual" + appendTo={document.body} triggerTarget={selectRef.current} trigger={null} onShow={() => { diff --git a/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx b/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx index f900f62ae8..fdacb90930 100644 --- a/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx +++ b/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx @@ -1,10 +1,11 @@ import { useCallback, useState } from 'react' -import { SvgActionEdit, SvgActionLinkUrl, SvgActionSell } from '@/assets/icons' +import { SvgActionEdit, SvgActionLinkUrl, SvgActionRevenueShare, SvgActionSell } from '@/assets/icons' import { LimitedWidthContainer } from '@/components/LimitedWidthContainer' import { Tabs } from '@/components/Tabs' import { Text } from '@/components/Text' import { Button } from '@/components/_buttons/Button' +import { StartRevenueShare } from '@/components/_crt/StartRevenueShareModal/StartRevenueShareModal' import { HeaderContainer, MainContainer, TabsContainer } from '@/views/studio/CrtDashboard/CrtDashboard.styles' import { CrtDashboardMainTab } from '@/views/studio/CrtDashboard/tabs/CrtDashboardMainTab' import { CrtHoldersTab } from '@/views/studio/CrtDashboard/tabs/CrtHoldersTab' @@ -14,6 +15,7 @@ const TABS = ['Dashboard', 'Holders', 'Revenue share', 'Settings'] as const export const CrtDashboard = () => { const [currentTab, setCurrentTab] = useState(0) + const [openRevenueShareModal, setOpenRevenueShareModal] = useState(false) const handleChangeTab = useCallback((idx: number) => { setCurrentTab(idx) }, []) @@ -22,6 +24,7 @@ export const CrtDashboard = () => { return ( + setOpenRevenueShareModal(false)} /> @@ -34,10 +37,19 @@ export const CrtDashboard = () => { - - + {currentTab === 0 && ( + <> + + + + )} + {currentTab === 2 && ( + + )} {currentTab === 0 && } {currentTab === 1 && } From b5c48107a5a134688c6c972f92f5f17814a79865 Mon Sep 17 00:00:00 2001 From: WRadoslaw <92513933+WRadoslaw@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:15:43 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=8E=AC=20Close=20revenue=20share=20(#?= =?UTF-8?q?4909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump types package * Return extra info from transaction * Add transaction to finalize revenue split * Fix deps array * Fix build --- .../atlas/src/joystream-lib/extrinsics.ts | 8 ++--- .../studio/CrtDashboard/CrtDashboard.tsx | 35 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/atlas/src/joystream-lib/extrinsics.ts b/packages/atlas/src/joystream-lib/extrinsics.ts index 7991dd46fd..3f773c863c 100644 --- a/packages/atlas/src/joystream-lib/extrinsics.ts +++ b/packages/atlas/src/joystream-lib/extrinsics.ts @@ -1076,15 +1076,15 @@ export class JoystreamLibExtrinsics { return this.api.tx.content.finalizeRevenueSplit(member, parseInt(channelId)) } - finalizeRevenueSplit: PublicExtrinsic = async ( + finalizeRevenueSplit: PublicExtrinsic = async ( memberId, channelId, cb ) => { const tx = await this.finalizeRevenueSplitTx(memberId, channelId) - const { block } = await this.sendExtrinsic(tx, cb) - - return { block } + const { block, getEventData } = await this.sendExtrinsic(tx, cb) + const amount = getEventData('projectToken', 'RevenueSplitFinalized')[2] + return { block, amount: amount.toString() } } deissueCreatorTokenTx = async (memberId: MemberId, channelId: ChannelId) => { diff --git a/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx b/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx index fdacb90930..07c321cbda 100644 --- a/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx +++ b/packages/atlas/src/views/studio/CrtDashboard/CrtDashboard.tsx @@ -6,6 +6,11 @@ import { Tabs } from '@/components/Tabs' import { Text } from '@/components/Text' import { Button } from '@/components/_buttons/Button' import { StartRevenueShare } from '@/components/_crt/StartRevenueShareModal/StartRevenueShareModal' +import { atlasConfig } from '@/config' +import { useJoystream } from '@/providers/joystream' +import { useSnackbar } from '@/providers/snackbars' +import { useTransaction } from '@/providers/transactions/transactions.hooks' +import { useUser } from '@/providers/user/user.hooks' import { HeaderContainer, MainContainer, TabsContainer } from '@/views/studio/CrtDashboard/CrtDashboard.styles' import { CrtDashboardMainTab } from '@/views/studio/CrtDashboard/tabs/CrtDashboardMainTab' import { CrtHoldersTab } from '@/views/studio/CrtDashboard/tabs/CrtHoldersTab' @@ -16,12 +21,33 @@ const TABS = ['Dashboard', 'Holders', 'Revenue share', 'Settings'] as const export const CrtDashboard = () => { const [currentTab, setCurrentTab] = useState(0) const [openRevenueShareModal, setOpenRevenueShareModal] = useState(false) + const { joystream, proxyCallback } = useJoystream() + const { channelId, memberId } = useUser() + const { displaySnackbar } = useSnackbar() + const handleTransaction = useTransaction() const handleChangeTab = useCallback((idx: number) => { setCurrentTab(idx) }, []) const mappedTabs = TABS.map((tab) => ({ name: tab })) + const finalizeRevenueShare = useCallback(() => { + if (!joystream || !memberId || !channelId) { + return + } + handleTransaction({ + txFactory: async (updateStatus) => + (await joystream.extrinsics).finalizeRevenueSplit(memberId, channelId, proxyCallback(updateStatus)), + onTxSync: async (data) => { + displaySnackbar({ + title: 'Revenue share is closed', + description: `Remaining unclaimed ${data.amount} ${atlasConfig.joystream.tokenTicker} was transfered back to your channel balance`, + iconType: 'info', + }) + }, + }) + }, [channelId, displaySnackbar, handleTransaction, joystream, memberId, proxyCallback]) + return ( setOpenRevenueShareModal(false)} /> @@ -46,9 +72,12 @@ export const CrtDashboard = () => { )} {currentTab === 2 && ( - + <> + + + )} {currentTab === 0 && } From 7ee3e76c38b2e150e5ccf4b5a9129aaa1af397e6 Mon Sep 17 00:00:00 2001 From: WRadoslaw <92513933+WRadoslaw@users.noreply.github.com> Date: Thu, 12 Oct 2023 09:36:02 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A5=8B=20Buy=20token=20market=20tx=20?= =?UTF-8?q?(#4946)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add amm transactions to the worker * Add rhf and basic validation * Add transaction to final submit * Make percentages to permills * Fix perquintill values * Adjust perquintill v2 --- .../BuyMarketTokenModal.tsx | 43 +++++++++++-- .../steps/BuyMarketTokenForm.tsx | 63 +++++++++++++------ .../atlas/src/joystream-lib/extrinsics.ts | 58 ++++++++++++++++- 3 files changed, 137 insertions(+), 27 deletions(-) diff --git a/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx b/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx index 49d3b192e3..1cba963587 100644 --- a/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx +++ b/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx @@ -1,9 +1,14 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { BuyMarketTokenSuccess } from '@/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenSuccess' import { DialogProps } from '@/components/_overlays/Dialog' import { DialogModal } from '@/components/_overlays/DialogModal' import { useMediaMatch } from '@/hooks/useMediaMatch' +import { tokenNumberToHapiBn } from '@/joystream-lib/utils' +import { useJoystream } from '@/providers/joystream' +import { useSnackbar } from '@/providers/snackbars' +import { useTransaction } from '@/providers/transactions/transactions.hooks' +import { useUser } from '@/providers/user/user.hooks' import { BuyMarketTokenConditions } from './steps/BuyMarketTokenConditions' import { BuySaleTokenForm, getTokenDetails } from './steps/BuyMarketTokenForm' @@ -23,8 +28,12 @@ export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps const { title } = getTokenDetails(tokenId) const [activeStep, setActiveStep] = useState(BUY_MARKET_TOKEN_STEPS.form) const [primaryButtonProps, setPrimaryButtonProps] = useState() + const amountRef = useRef(null) + const { memberId } = useUser() const smMatch = useMediaMatch('sm') - + const { displaySnackbar } = useSnackbar() + const { joystream, proxyCallback } = useJoystream() + const handleTransaction = useTransaction() const secondaryButton = useMemo(() => { switch (activeStep) { case BUY_MARKET_TOKEN_STEPS.conditions: @@ -47,7 +56,30 @@ export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps setPrimaryButtonProps, } - const onSubmitConditions = useCallback(() => setActiveStep(BUY_MARKET_TOKEN_STEPS.success), []) + const onSubmitConditions = useCallback(async () => { + if (!joystream || !memberId || !amountRef.current) { + return + } + + handleTransaction({ + txFactory: async (updateStatus) => + (await joystream.extrinsics).purchaseTokenOnMarket( + tokenId, + memberId, + tokenNumberToHapiBn(amountRef.current as number).toString(), + proxyCallback(updateStatus) + ), + onTxSync: async () => { + setActiveStep(BUY_MARKET_TOKEN_STEPS.success) + }, + onError: () => { + setActiveStep(BUY_MARKET_TOKEN_STEPS.form) + displaySnackbar({ + title: 'Something went wrong', + }) + }, + }) + }, [displaySnackbar, handleTransaction, joystream, memberId, proxyCallback, tokenId]) return ( setActiveStep(BUY_MARKET_TOKEN_STEPS.conditions)} + onSubmit={(tokens) => { + setActiveStep(BUY_MARKET_TOKEN_STEPS.conditions) + amountRef.current = tokens + }} tokenId={tokenId} /> )} diff --git a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx b/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx index 17b0dff9a0..831e832f58 100644 --- a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx +++ b/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' +import { Controller, useForm } from 'react-hook-form' import { FlexBox } from '@/components/FlexBox/FlexBox' import { Information } from '@/components/Information' @@ -12,7 +13,8 @@ import { DetailsContent } from '@/components/_nft/NftTile' import { atlasConfig } from '@/config' import { useMediaMatch } from '@/hooks/useMediaMatch' import { useMountEffect } from '@/hooks/useMountEffect' -import { useJoystream } from '@/providers/joystream' +import { hapiBnToTokenNumber } from '@/joystream-lib/utils' +import { useJoystream, useSubscribeAccountBalance } from '@/providers/joystream' import { CommonProps } from './types' @@ -25,11 +27,13 @@ export const getTokenDetails = (_: string) => ({ type BuySaleTokenFormProps = { tokenId: string - onSubmit: () => void + onSubmit: (tokens: number | null) => void } & CommonProps export const BuySaleTokenForm = ({ tokenId, setPrimaryButtonProps, onSubmit }: BuySaleTokenFormProps) => { - const [tokens, setTokens] = useState(null) + const { control, watch, handleSubmit } = useForm<{ tokens: number | null }>() + const { accountBalance } = useSubscribeAccountBalance() + const tokens = watch('tokens') const { pricePerUnit, tokensOnSale, userBalance, title } = getTokenDetails(tokenId) const { tokenPrice } = useJoystream() const tokenInUsd = (tokens || 0) * pricePerUnit * (tokenPrice ?? 0) @@ -70,7 +74,7 @@ export const BuySaleTokenForm = ({ tokenId, setPrimaryButtonProps, onSubmit }: B useMountEffect(() => { setPrimaryButtonProps({ text: 'Continue', - onClick: () => onSubmit(), + onClick: () => handleSubmit((data) => onSubmit(data.tokens))(), }) }) @@ -97,21 +101,40 @@ export const BuySaleTokenForm = ({ tokenId, setPrimaryButtonProps, onSubmit }: B withDenomination /> - - - - ${tokenInUsd.toFixed(2)} - - setTokens(Math.floor(userBalance / pricePerUnit))}>Max - - } - /> - + ( + + + + ${tokenInUsd.toFixed(2)} + + + accountBalance && field.onChange(Math.floor(hapiBnToTokenNumber(accountBalance) / pricePerUnit)) + } + > + Max + + + } + /> + + )} + /> {details.map((row, i) => ( diff --git a/packages/atlas/src/joystream-lib/extrinsics.ts b/packages/atlas/src/joystream-lib/extrinsics.ts index 3f773c863c..c2f7d44a8c 100644 --- a/packages/atlas/src/joystream-lib/extrinsics.ts +++ b/packages/atlas/src/joystream-lib/extrinsics.ts @@ -71,6 +71,9 @@ type PublicExtrinsic = TxFunction extends (...a: infer ? (...a: [...U, ExtrinsicStatusCallbackFn | undefined]) => Promise : never +const PERMILLS_PER_PERCENTAGE = 10 +const PERQUINTILLS_PER_PERCENTAGE = new BN(10).pow(new BN(16)) + export class JoystreamLibExtrinsics { readonly api: PolkadotApi readonly getAccount: AccountIdAccessor @@ -1126,7 +1129,7 @@ export class JoystreamLibExtrinsics { linearVestingDuration: createType('u32', new BN(initialCreatorAllocation.vestingDuration)), cliffAmountPercentage: createType( 'Permill', - new BN(initialCreatorAllocation.cliffAmountPercentage) + new BN(initialCreatorAllocation.cliffAmountPercentage * PERMILLS_PER_PERCENTAGE) ) as number, }), }) @@ -1134,8 +1137,8 @@ export class JoystreamLibExtrinsics { const params = createType('PalletProjectTokenTokenIssuanceParameters', { initialAllocation, - patronageRate: createType('Perquintill', patronageRate) as number, - revenueSplitRate: createType('Permill', revenueSplitRate) as number, + patronageRate: createType('Perquintill', PERQUINTILLS_PER_PERCENTAGE.muln(patronageRate)) as number, + revenueSplitRate: createType('Permill', revenueSplitRate * PERMILLS_PER_PERCENTAGE) as number, transferPolicy: createType('PalletProjectTokenTransferPolicyParams', 'Permissionless'), metadata: prepareCreatorTokenMetadata({ symbol }), }) @@ -1163,4 +1166,53 @@ export class JoystreamLibExtrinsics { return { block } } + + purchaseTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string) => { + const amountCast = createType('u128', new BN(amount)) + + return this.api.tx.projectToken.buyOnAmm( + parseInt(tokenId), + parseInt(memberId), + amountCast, + createType('Option>', [ + createType('Permill', new BN(0.5 * PERMILLS_PER_PERCENTAGE)), + amountCast, + ]) // percent, number of joy user wants to pay --- default on 0.5% + ) + } + + purchaseTokenOnMarket: PublicExtrinsic = async ( + tokenId, + memberId, + amount, + cb + ) => { + const tx = await this.purchaseTokenOnMarketTx(tokenId, memberId, amount) + const { block } = await this.sendExtrinsic(tx, cb) + return { block } + } + + sellTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string) => { + const amountCast = createType('u128', new BN(amount)) + return this.api.tx.projectToken.sellOnAmm( + parseInt(tokenId), + parseInt(memberId), + amountCast, + createType('Option>', [ + createType('Permill', new BN(0.5 * PERMILLS_PER_PERCENTAGE)), + amountCast, + ]) // percent, number of joy user wants to pay --- default on 0.5% + ) + } + + sellTokenOnMarket: PublicExtrinsic = async ( + tokenId, + memberId, + amount, + cb + ) => { + const tx = await this.sellTokenOnMarketTx(tokenId, memberId, amount) + const { block } = await this.sendExtrinsic(tx, cb) + return { block } + } } From 76e1b47b0970bdf1b3230524a073502e642123eb Mon Sep 17 00:00:00 2001 From: attemka Date: Thu, 12 Oct 2023 17:02:07 +0200 Subject: [PATCH 4/6] Crt market start (#4884) * marketStart drawer * chart ticks fix, minor markup fix * refactor, validation, chart fixes * lint * preserve form value after changing the step --------- Co-authored-by: Artem --- packages/atlas/atlas.config.yml | 2 + packages/atlas/package.json | 16 +- .../components/NumberFormat/NumberFormat.tsx | 2 +- .../_auth/SignUpModal/SignUpModal.tsx | 6 +- .../_charts/LineChart/LineChart.tsx | 49 +- .../MarketDrawer/MarketDrawer.stories.tsx | 49 + .../_crt/MarketDrawer/MarketDrawer.styles.ts | 13 + .../_crt/MarketDrawer/MarketDrawer.tsx | 108 + .../_crt/MarketDrawer/MarketDrawer.types.ts | 5 + .../_crt/MarketDrawer/MarketDrawerPreview.tsx | 99 + .../src/components/_crt/MarketDrawer/index.ts | 1 + .../_crt/MarketDrawer/steps/MarketStep.tsx | 191 + .../MarketDrawer/steps/SaleSummaryStep.tsx | 78 + packages/atlas/src/config/configSchema.ts | 1 + .../studio/CrtMarketView/CrtMarketView.tsx | 12 + .../src/views/studio/CrtMarketView/index.ts | 1 + yarn.lock | 4364 ++++++++++------- 17 files changed, 3227 insertions(+), 1770 deletions(-) create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.stories.tsx create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.styles.ts create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.tsx create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.types.ts create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/MarketDrawerPreview.tsx create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/index.ts create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/steps/MarketStep.tsx create mode 100644 packages/atlas/src/components/_crt/MarketDrawer/steps/SaleSummaryStep.tsx create mode 100644 packages/atlas/src/views/studio/CrtMarketView/CrtMarketView.tsx create mode 100644 packages/atlas/src/views/studio/CrtMarketView/index.ts diff --git a/packages/atlas/atlas.config.yml b/packages/atlas/atlas.config.yml index 27a0ea5eee..6f62281433 100644 --- a/packages/atlas/atlas.config.yml +++ b/packages/atlas/atlas.config.yml @@ -474,6 +474,8 @@ analytics: id: '$VITE_SEGMENT_ID' legal: + crtTnc: | + This is a temporary placeholder for the Creator Tokens Terms and Conditions. The final version will be added here soon. termsOfService: | # Terms of Service diff --git a/packages/atlas/package.json b/packages/atlas/package.json index 115f4a0116..3cc07b3bb6 100644 --- a/packages/atlas/package.json +++ b/packages/atlas/package.json @@ -117,13 +117,13 @@ "@joystream/prettier-config": "^1.0.0", "@modyfi/vite-plugin-yaml": "^1.0.3", "@rollup/plugin-babel": "^6.0.3", - "@storybook/addon-actions": "7.0.7", - "@storybook/addon-docs": "7.0.7", - "@storybook/addon-essentials": "7.0.7", - "@storybook/addon-links": "7.0.7", - "@storybook/addons": "7.0.7", - "@storybook/react-vite": "7.0.7", - "@storybook/theming": "7.0.7", + "@storybook/addon-actions": "7.4.6", + "@storybook/addon-docs": "7.4.6", + "@storybook/addon-essentials": "7.4.6", + "@storybook/addon-links": "7.4.6", + "@storybook/addons": "7.4.6", + "@storybook/react-vite": "7.4.6", + "@storybook/theming": "7.4.6", "@svgr/cli": "^6.5.1", "@testing-library/dom": "^8.19.0", "@testing-library/jest-dom": "^5.16.5", @@ -151,7 +151,7 @@ "react-hooks-testing-library": "^0.6.0", "rimraf": "^3.0.2", "rollup-plugin-visualizer": "^5.8.3", - "storybook": "7.0.7", + "storybook": "7.4.6", "style-dictionary": "^3.7.1", "vite": "^4.3.9", "vite-plugin-checker": "^0.5.2", diff --git a/packages/atlas/src/components/NumberFormat/NumberFormat.tsx b/packages/atlas/src/components/NumberFormat/NumberFormat.tsx index 24bdf9ffcf..c630bdba28 100644 --- a/packages/atlas/src/components/NumberFormat/NumberFormat.tsx +++ b/packages/atlas/src/components/NumberFormat/NumberFormat.tsx @@ -230,7 +230,7 @@ const dollarSmallNumberFormatter = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3, }) -const formatNumberShort = (num: number): string => { +export const formatNumberShort = (num: number): string => { return numberCompactFormatter.format(num).replaceAll(',', ' ') } diff --git a/packages/atlas/src/components/_auth/SignUpModal/SignUpModal.tsx b/packages/atlas/src/components/_auth/SignUpModal/SignUpModal.tsx index 3a26e5f6b2..e6dc2f0cb1 100644 --- a/packages/atlas/src/components/_auth/SignUpModal/SignUpModal.tsx +++ b/packages/atlas/src/components/_auth/SignUpModal/SignUpModal.tsx @@ -54,7 +54,7 @@ export const SignUpModal = () => { const [emailAlreadyTakenError, setEmailAlreadyTakenError] = useState(false) const [hasNavigatedBack, setHasNavigatedBack] = useState(false) const [primaryButtonProps, setPrimaryButtonProps] = useState({ text: 'Continue' }) - const [amountOfTokens, setAmountofTokens] = useState() + const [amountOfTokens] = useState() const [memberId, setMemberId] = useState(null) const syncState = useRef<'synced' | 'tried' | null>(null) const ytResponseData = useYppStore((state) => state.ytResponseData) @@ -128,7 +128,7 @@ export const SignUpModal = () => { setAuthModalOpenName(undefined) setYppModalOpenName('ypp-sync-options') } else { - setAmountofTokens(amountOfTokens) + amountOfTokens goToNextStep() } }, @@ -174,7 +174,7 @@ export const SignUpModal = () => { setAuthModalOpenName(undefined) setYppModalOpenName('ypp-sync-options') } else { - setAmountofTokens(amountOfTokens) + amountOfTokens goToNextStep() } }, diff --git a/packages/atlas/src/components/_charts/LineChart/LineChart.tsx b/packages/atlas/src/components/_charts/LineChart/LineChart.tsx index e733961aac..07d33cb005 100644 --- a/packages/atlas/src/components/_charts/LineChart/LineChart.tsx +++ b/packages/atlas/src/components/_charts/LineChart/LineChart.tsx @@ -4,6 +4,31 @@ import { ReactNode } from 'react' import { cVar, sizes } from '@/styles' +export const defaultChartTheme = { + tooltip: { + container: { + background: cVar('colorBackgroundStrong'), + }, + }, + + axis: { + ticks: { + line: { + stroke: cVar('colorBackgroundAlpha'), + }, + text: { + fill: cVar('colorTextMuted'), + font: cVar('typographyDesktopT100'), + }, + }, + }, + grid: { + line: { + stroke: cVar('colorBackgroundAlpha'), + }, + }, +} + const defaultJoystreamProps: Omit = { isInteractive: true, useMesh: true, @@ -29,29 +54,7 @@ const defaultJoystreamProps: Omit = { tickValues: 6, }, colors: (d) => d.color, - theme: { - tooltip: { - container: { - background: cVar('colorBackgroundStrong'), - }, - }, - axis: { - ticks: { - line: { - stroke: cVar('colorBackgroundAlpha'), - }, - text: { - fill: cVar('colorTextMuted'), - font: cVar('typographyDesktopT100'), - }, - }, - }, - grid: { - line: { - stroke: cVar('colorBackgroundAlpha'), - }, - }, - }, + theme: defaultChartTheme, } export type LineChartProps = { tooltip?: (point: Point) => ReactNode diff --git a/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.stories.tsx b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.stories.tsx new file mode 100644 index 0000000000..8b5a38564a --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.stories.tsx @@ -0,0 +1,49 @@ +import { ApolloProvider } from '@apollo/client' +import { Meta, StoryFn } from '@storybook/react' + +import { createApolloClient } from '@/api' +import { CrtMarketSaleViewProps, MarketDrawer } from '@/components/_crt/MarketDrawer' +import { AuthProvider } from '@/providers/auth/auth.provider' +import { ConfirmationModalProvider } from '@/providers/confirmationModal' +import { JoystreamProvider } from '@/providers/joystream/joystream.provider' +import { OverlayManagerProvider } from '@/providers/overlayManager' +import { SegmentAnalyticsProvider } from '@/providers/segmentAnalytics/segment.provider' +import { UserProvider } from '@/providers/user/user.provider' +import { WalletProvider } from '@/providers/wallet/wallet.provider' + +export default { + title: 'crt/CrtMarket', + component: MarketDrawer, + decorators: [ + (Story) => ( + + + + + + + + + + + + + + + + + + ), + ], +} as Meta + +const Template: StoryFn = (args) => + +export const Default = Template.bind({}) +Default.args = { + show: true, + onClose: () => { + return null + }, + tokenName: 'JBC', +} diff --git a/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.styles.ts b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.styles.ts new file mode 100644 index 0000000000..1e112d2d9b --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.styles.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled' + +import { sizes } from '@/styles' +import { Divider } from '@/views/global/NftSaleBottomDrawer/NftForm/AcceptTerms/AcceptTerms.styles' + +export const ChartBox = styled.div` + height: 300px; + width: 100%; +` + +export const HDivider = styled(Divider)` + margin: ${sizes(4)} 0; +` diff --git a/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.tsx b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.tsx new file mode 100644 index 0000000000..4c810a4d0c --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.tsx @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { CSSTransition, SwitchTransition } from 'react-transition-group' + +import { ActionDialogButtonProps } from '@/components/ActionBar' +import { CrtDrawer } from '@/components/CrtDrawer' +import { CrtMarketForm } from '@/components/_crt/MarketDrawer/MarketDrawer.types' +import { MarketDrawerPreview } from '@/components/_crt/MarketDrawer/MarketDrawerPreview' +import { MarketStep } from '@/components/_crt/MarketDrawer/steps/MarketStep' +import { SaleSummaryStep } from '@/components/_crt/MarketDrawer/steps/SaleSummaryStep' +import { atlasConfig } from '@/config' +import { transitions } from '@/styles' + +enum MARKET_STEPS { + market, + saleSummary, +} +const marketStepsNames: string[] = ['Market', 'Sale summary'] + +export type CrtMarketSaleViewProps = { + tokenName: string + show: boolean + onClose: () => void +} + +export const MarketDrawer = ({ show, onClose, tokenName }: CrtMarketSaleViewProps) => { + const [activeStep, setActiveStep] = useState(MARKET_STEPS.market) + const [marketData, setMarketData] = useState({ + price: 0, + tnc: atlasConfig.legal.crtTnc, + isChecked: true, + }) + const [primaryButtonProps, setPrimaryButtonProps] = useState({ text: 'Continue' }) + const [secondaryButtonProps, setSecondaryButtonProps] = useState({ text: 'Back' }) + const [isGoingBack, setIsGoingBack] = useState(false) + const nodeRef = useRef(null) + + const handleNextStep = useCallback( + ({ price, tnc }: CrtMarketForm) => { + setMarketData({ ...marketData, price, tnc }) + setActiveStep(MARKET_STEPS.saleSummary) + }, + [marketData] + ) + + const handleBackClick = useCallback(() => { + flushSync(() => { + setIsGoingBack(true) + }) + setActiveStep(MARKET_STEPS.market) + }, []) + + const stepContent = () => { + switch (activeStep) { + case MARKET_STEPS.market: + return ( + + ) + case MARKET_STEPS.saleSummary: + return ( + + ) + } + } + + return ( + } + > + + { + nodeRef.current?.addEventListener('transitionend', done, false) + }} + onEntered={() => setIsGoingBack(false)} + classNames={isGoingBack ? transitions.names.backwardSlideSwitch : transitions.names.forwardSlideSwitch} + > +
{stepContent()}
+
+
+
+ ) +} diff --git a/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.types.ts b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.types.ts new file mode 100644 index 0000000000..38554f0ec2 --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawer.types.ts @@ -0,0 +1,5 @@ +export type CrtMarketForm = { + price: number + isChecked: boolean + tnc: string +} diff --git a/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawerPreview.tsx b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawerPreview.tsx new file mode 100644 index 0000000000..101e840f79 --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/MarketDrawerPreview.tsx @@ -0,0 +1,99 @@ +import { Datum } from '@nivo/line' + +import { SvgJoyTokenMonochrome16 } from '@/assets/icons' +import { NumberFormat, formatNumberShort } from '@/components/NumberFormat' +import { Text } from '@/components/Text' +import { LineChart, defaultChartTheme } from '@/components/_charts/LineChart' +import { TooltipBox } from '@/components/_crt/CreateTokenDrawer/steps/styles' +import { ChartBox } from '@/components/_crt/MarketDrawer/MarketDrawer.styles' +import { cVar } from '@/styles' + +type MarketDrawerPreviewProps = { + tokenName: string + startingPrice: number +} + +const DEFAULT_AMM_SENSIVITY = 1 +export const MarketDrawerPreview = ({ tokenName, startingPrice }: MarketDrawerPreviewProps) => { + const issuedTokens = [10 ** 3, 10 ** 4, 5 * 10 ** 4, 10 ** 5, 5 * 10 ** 5, 10 ** 6, 10 ** 7] + + const chartData: Datum[] = issuedTokens.map((num) => ({ + x: formatNumberShort(num), + y: DEFAULT_AMM_SENSIVITY * num + startingPrice, + })) + + const getTickValues = () => [ + ...new Set( + issuedTokens.map((elem) => { + const floor = Math.pow(10, Math.round(Math.log10(DEFAULT_AMM_SENSIVITY * elem + startingPrice))) + return Math.max(Math.floor(elem / floor), 1) * floor + }) + ), + ] + + return ( + <> + + { + return ( + + + + + + {point.data.xFormatted} {tokenName} supply + + + ) + }} + yScale={{ + type: 'log', + base: 2, + min: 'auto', + max: 'auto', + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickValues: getTickValues(), + ticksPosition: 'before', + format: (tick) => formatNumberShort(tick), + // eslint-disable-next-line + // @ts-ignore + renderTick: ({ x, y, textX, textY, opacity, textBaseline, value, format }) => { + const iconX = textX - 18 + const iconY = textY - 8 + return ( + + + + {format && format(value)} + + + ) + }, + }} + gridYValues={getTickValues()} + data={[ + { + id: 1, + color: cVar('colorCoreBlue500'), + data: chartData, + }, + ]} + enableCrosshair={false} + /> + + + ) +} diff --git a/packages/atlas/src/components/_crt/MarketDrawer/index.ts b/packages/atlas/src/components/_crt/MarketDrawer/index.ts new file mode 100644 index 0000000000..19a92d5425 --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/index.ts @@ -0,0 +1 @@ +export * from './MarketDrawer' diff --git a/packages/atlas/src/components/_crt/MarketDrawer/steps/MarketStep.tsx b/packages/atlas/src/components/_crt/MarketDrawer/steps/MarketStep.tsx new file mode 100644 index 0000000000..39ff2dc0a3 --- /dev/null +++ b/packages/atlas/src/components/_crt/MarketDrawer/steps/MarketStep.tsx @@ -0,0 +1,191 @@ +import { FC, useCallback, useEffect } from 'react' +import { Controller, useForm } from 'react-hook-form' + +import { SvgActionPlay } from '@/assets/icons' +import { ActionDialogButtonProps } from '@/components/ActionBar' +import { FlexBox } from '@/components/FlexBox' +import { NumberFormat } from '@/components/NumberFormat' +import { ColumnBox } from '@/components/ProgressWidget/ProgressWidget.styles' +import { Text } from '@/components/Text' +import { TextButton } from '@/components/_buttons/Button' +import { CrtMarketForm } from '@/components/_crt/MarketDrawer/MarketDrawer.types' +import { Checkbox } from '@/components/_inputs/Checkbox' +import { FormField } from '@/components/_inputs/FormField' +import { TextArea } from '@/components/_inputs/TextArea' +import { TokenInput } from '@/components/_inputs/TokenInput' +import { atlasConfig } from '@/config' +import { useConfirmationModal } from '@/providers/confirmationModal' +import { useJoystream } from '@/providers/joystream' + +type MarketStepProps = { + setPrimaryButtonProps: (props: ActionDialogButtonProps) => void + setSecondaryButtonProps: (props: ActionDialogButtonProps) => void + tokenName: string + formDefaultValue: CrtMarketForm + onClose: () => void + onNextStep: (props: CrtMarketForm) => void +} + +const DEFAULT_MIN_PRICE = 100 + +export const MarketStep: FC = ({ + tokenName, + setPrimaryButtonProps, + onNextStep, + formDefaultValue, + setSecondaryButtonProps, + onClose, +}) => { + const { tokenPrice } = useJoystream() + const { + control, + handleSubmit, + resetField, + watch, + formState: { isDirty, errors }, + } = useForm({ + defaultValues: formDefaultValue, + }) + + const [openDialog, closeDialog] = useConfirmationModal({ + type: 'warning', + title: 'Discard changes?', + description: + 'You have unsaved changes which are going to be lost if you close this window. Are you sure you want to continue?', + primaryButton: { + variant: 'warning', + text: 'Confirm and discard', + onClick: () => { + closeDialog() + onClose() + }, + }, + secondaryButton: { + text: 'Cancel', + onClick: () => closeDialog(), + }, + }) + + const isChecked = watch('isChecked') + const price = watch('price') + + const tokenInUsd = (price || 0) * (tokenPrice || 0) + + const handleGoToNextStep = useCallback(() => { + handleSubmit((data) => { + onNextStep(data) + })() + }, [handleSubmit, onNextStep]) + + useEffect(() => { + setPrimaryButtonProps({ + text: 'Next', + onClick: () => { + handleGoToNextStep() + }, + }) + setSecondaryButtonProps({ + text: 'Cancel', + onClick: () => (isDirty ? onClose() : openDialog()), + }) + }, [handleGoToNextStep, isDirty, onClose, openDialog, setPrimaryButtonProps, setSecondaryButtonProps]) + + return ( + + + + Market + + } iconPlacement="left" color="colorTextPrimary"> + Learn more + + + + Automated market maker (AMM) will increase ${tokenName} price after each purchase and decrease its price when + someone sells it to the AMM. + + + You cannot set price lower than + + } + error={errors.price?.message} + > + ( + + ${tokenInUsd.toFixed(2)} +
+ } + /> + )} + rules={{ + validate: { + price: (value) => { + if (!value) { + return 'Enter starting token price' + } + return true + }, + minPrice: (value) => { + if (value < DEFAULT_MIN_PRICE) { + return `Price cannot be lower than ${DEFAULT_MIN_PRICE} ${atlasConfig.joystream.tokenTicker}` + } + return true + }, + }, + }} + name="price" + /> + + + + ( + { + if (checked) { + resetField('tnc') + } + onChange(checked) + }} + /> + )} + name="isChecked" + /> + { + if (!value) { + return 'You need to fill in the terms and conditions to proceed' + } + return true + }, + }, + }} + render={({ field: { value: tnc, onChange } }) => ( +