From 5be6a75e4b74ba86fcf8261eadc837ff45a28b1d Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Fri, 21 Jan 2022 13:33:58 -0500 Subject: [PATCH] Create new markets directly from the feed (#33) * Move hot & closing markets to All Markets Soon to be named "Explore" * Set up a question box on the homepage * Show Leaderboards by default instead of Create * Support full contract creation from homepage * Force mt-0 on /markets * Move description into info tooltip * Add hint to ask a question * Initialize close date to 1 week from today * Add a promo section for logged-out users * Add hashtags, remove mobile padding * Rewrite promo copy * Store hotContracts as static props --- web/components/contract-feed.tsx | 2 +- web/components/feed-create.tsx | 119 +++++++++++++ web/components/nav-bar.tsx | 13 +- web/components/profile-menu.tsx | 4 - web/pages/activity.tsx | 1 - web/pages/create.tsx | 278 ++++++++++++++++--------------- web/pages/index.tsx | 75 ++++----- web/pages/markets.tsx | 56 ++++++- 8 files changed, 349 insertions(+), 199 deletions(-) create mode 100644 web/components/feed-create.tsx diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 064ca7fc..c97a3b29 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -40,7 +40,7 @@ import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' import { JoinSpans } from './join-spans' import Textarea from 'react-expanding-textarea' -function AvatarWithIcon(props: { username: string; avatarUrl: string }) { +export function AvatarWithIcon(props: { username: string; avatarUrl: string }) { const { username, avatarUrl } = props return ( diff --git a/web/components/feed-create.tsx b/web/components/feed-create.tsx new file mode 100644 index 00000000..aac155ef --- /dev/null +++ b/web/components/feed-create.tsx @@ -0,0 +1,119 @@ +import { useUser } from '../hooks/use-user' +import { AvatarWithIcon } from './contract-feed' +import { Title } from './title' +import Textarea from 'react-expanding-textarea' +import { useState } from 'react' +import { Spacer } from './layout/spacer' +import { NewContract } from '../pages/create' +import { firebaseLogin, User } from '../lib/firebase/users' +import { useHotContracts } from '../hooks/use-contracts' +import { ContractsGrid } from './contracts-list' +import { SiteLink } from './site-link' +import { Contract } from '../../common/contract' + +export function FeedPromo(props: { hotContracts: Contract[] }) { + const contracts = useHotContracts() ?? props.hotContracts + + return ( + <> +
+ + <div className="text-gray-500 mb-4"> + On Manifold Markets, you can find prediction markets run by your + favorite creators. + <br /> + <button + className="bg-gradient-to-r gradient-to-r from-teal-500 to-green-500 text-transparent bg-clip-text hover:underline hover:decoration-gray-300 hover:decoration-2" + onClick={firebaseLogin} + > + Sign up to get M$ 1000 for free + </button>{' '} + and start trading! + <br /> + </div> + + <div className="flex flex-wrap mt-2 gap-2"> + {['#politics', '#covid', '#gaming', '#sports', '#meta'].map((tag) => ( + <Hashtag tag={tag} /> + ))} + </div> + <Spacer h={4} /> + + <ContractsGrid contracts={contracts?.slice(0, 2) || []} showHotVolume /> + </div> + + <div className="text-gray-800 text-lg mb-0 mt-6 mx-6"> + Recent community activity + </div> + </> + ) +} + +function Hashtag(props: { tag: string }) { + const { tag } = props + return ( + <SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center"> + <div className="bg-white hover:bg-gray-100 cursor-pointer px-4 py-2 rounded-full shadow-md"> + <span className="text-gray-500">{tag}</span> + </div> + </SiteLink> + ) +} + +export default function FeedCreate(props: { user: User }) { + const { user } = props + const [question, setQuestion] = useState('') + + const placeholders = [ + 'Will I make a new friend this week?', + 'Will we discover that the world is a simulation?', + 'Will anyone I know get engaged this year?', + 'Will humans set foot on Mars by the end of 2030?', + 'If I switch jobs, will I have more free time in 6 months than I do now?', + 'Will any cryptocurrency eclipse Bitcoin by market cap?', + ] + // Rotate through a new placeholder each day + // Easter egg idea: click your own name to shuffle the placeholder + const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24) + const placeholder = placeholders[daysSinceEpoch % placeholders.length] + + return ( + <div className="w-full bg-indigo-50 sm:rounded-md p-4"> + <div className="relative flex items-start space-x-3"> + <AvatarWithIcon + username={user.username} + avatarUrl={user.avatarUrl || ''} + /> + <div className="min-w-0 flex-1"> + {/* TODO: Show focus, for accessibility */} + <div> + <p className="my-0.5 text-sm">Ask a question... </p> + </div> + <Textarea + className="text-lg sm:text-xl text-indigo-700 w-full border-transparent focus:border-transparent bg-transparent p-0 appearance-none resize-none focus:ring-transparent" + placeholder={`e.g. ${placeholder}`} + value={question} + onClick={(e) => e.stopPropagation()} + onChange={(e) => setQuestion(e.target.value || '')} + /> + <Spacer h={4} /> + </div> + </div> + {/* Hide component instead of deleting, so edits to NewContract don't get lost */} + <div className={question ? '' : 'hidden'}> + <NewContract question={question} /> + </div> + {/* Show a fake "Create Market" button, which gets replaced with the NewContract one*/} + {!question && ( + <div className="flex justify-end"> + <button className="btn" disabled> + Create Market + </button> + </div> + )} + </div> + ) +} diff --git a/web/components/nav-bar.tsx b/web/components/nav-bar.tsx index 0b98506e..246864b1 100644 --- a/web/components/nav-bar.tsx +++ b/web/components/nav-bar.tsx @@ -70,26 +70,23 @@ function NavOptions(props: { user: User | null; themeClasses: string }) { {user === null ? ( <> - <div - className={clsx( - 'text-base font-medium cursor-pointer whitespace-nowrap', - themeClasses - )} + <button + className="btn border-none normal-case text-base font-medium px-6 bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600" onClick={firebaseLogin} > Sign in - </div> + </button> </> ) : ( <> - <Link href="/create"> + <Link href="/leaderboards"> <a className={clsx( 'text-base hidden md:block whitespace-nowrap', themeClasses )} > - Create a market + Leaderboards </a> </Link> diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index e97a337e..1d685769 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -51,10 +51,6 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) { name: 'Your markets', href: `/${user.username}`, }, - { - name: 'Leaderboards', - href: '/leaderboards', - }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx index 44f33617..e05ff1df 100644 --- a/web/pages/activity.tsx +++ b/web/pages/activity.tsx @@ -79,7 +79,6 @@ export function ActivityFeed(props: { return contracts.length > 0 ? ( <Col className="items-center"> <Col className="w-full max-w-3xl"> - <Title text="Recent Activity" /> <Col className="w-full bg-white self-center divide-gray-300 divide-y"> {activeContracts.map((contract, i) => ( <div key={contract.id} className="py-6 px-2 sm:px-4"> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ac1c9c51..870c2f8c 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -5,19 +5,50 @@ import dayjs from 'dayjs' import Textarea from 'react-expanding-textarea' import { Spacer } from '../components/layout/spacer' -import { Title } from '../components/title' import { useUser } from '../hooks/use-user' import { Contract, contractPath } from '../lib/firebase/contracts' -import { Page } from '../components/page' import { createContract } from '../lib/firebase/api-call' import { Row } from '../components/layout/row' import { AmountInput } from '../components/amount-input' import { MINIMUM_ANTE } from '../../common/antes' import { InfoTooltip } from '../components/info-tooltip' import { CREATOR_FEE } from '../../common/fees' +import { Page } from '../components/page' +import { Title } from '../components/title' + +export default function Create() { + const [question, setQuestion] = useState('') + + return ( + <Page> + <div className="w-full max-w-2xl mx-auto"> + <Title text="Create a new prediction market" /> + + <div className="bg-gray-100 rounded-lg shadow-md px-6 py-4"> + <form> + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1">Question</span> + </label> + + <Textarea + placeholder="e.g. Will the Democrats win the 2024 US presidential election?" + className="input input-bordered resize-none" + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + </div> + </form> + <NewContract question={question} /> + </div> + </div> + </Page> + ) +} // Allow user to create a new contract -export default function NewContract() { +export function NewContract(props: { question: string }) { + const question = props.question const creator = useUser() useEffect(() => { @@ -29,7 +60,6 @@ export default function NewContract() { }, []) const [initialProb, setInitialProb] = useState(50) - const [question, setQuestion] = useState('') const [description, setDescription] = useState('') const [ante, setAnte] = useState<number | undefined>(undefined) @@ -41,7 +71,9 @@ export default function NewContract() { }, [creator]) const [anteError, setAnteError] = useState<string | undefined>() - const [closeDate, setCloseDate] = useState('') + // By default, close the market a week from today + const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD') + const [closeDate, setCloseDate] = useState(weekFromToday) const [isSubmitting, setIsSubmitting] = useState(false) @@ -82,141 +114,119 @@ export default function NewContract() { await router.push(contractPath(result.contract as Contract)) } - // const descriptionPlaceholder = `e.g. This market will resolve to β€œYes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...` - const descriptionPlaceholder = `Provide more detail on how you will resolve this market. (Optional)` + const descriptionPlaceholder = `e.g. This market resolves to "YES" if, two weeks after closing, the...\n#politics #world` if (!creator) return <></> return ( - <Page> - <div className="w-full max-w-2xl mx-auto"> - <Title text="Create a new prediction market" /> + <form> + <Spacer h={4} /> - <div className="bg-gray-100 rounded-lg shadow-md px-6 py-4"> - <form> - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">Question</span> - </label> - - <Textarea - placeholder="e.g. Will the Democrats win the 2024 US presidential election?" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={question} - onChange={(e) => setQuestion(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control"> - <label className="label"> - <span className="mb-1">Initial probability</span> - </label> - <Row className="items-center gap-2"> - <label className="input-group input-group-lg w-fit text-lg"> - <input - type="number" - value={initialProb} - className="input input-bordered input-md text-lg" - disabled={isSubmitting} - min={1} - max={99} - onChange={(e) => - setInitialProb(parseInt(e.target.value.substring(0, 2))) - } - /> - <span>%</span> - </label> - <input - type="range" - className="range range-primary" - min={1} - max={99} - value={initialProb} - onChange={(e) => setInitialProb(parseInt(e.target.value))} - /> - </Row> - </div> - - <Spacer h={4} /> - - <div className="form-control"> - <label className="label"> - <span className="mb-1">Description</span> - </label> - <Textarea - className="textarea w-full textarea-bordered" - rows={3} - placeholder={descriptionPlaceholder} - value={description} - disabled={isSubmitting} - onClick={(e) => e.stopPropagation()} - onChange={(e) => setDescription(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control items-start mb-1"> - <label className="label gap-2 mb-1"> - <span>Last trading day</span> - <InfoTooltip text="Trading allowed through 11:59 pm local time on this date." /> - </label> - <input - type="date" - className="input input-bordered" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} - min={new Date().toISOString().split('T')[0]} - disabled={isSubmitting} - value={closeDate} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control items-start mb-1"> - <label className="label gap-2 mb-1"> - <span>Market ante</span> - <InfoTooltip - text={`Subsidize your market to encourage trading. Ante bets are set to match your initial probability. - You earn ${CREATOR_FEE * 100}% of trading volume.`} - /> - </label> - <AmountInput - amount={ante} - minimumAmount={MINIMUM_ANTE} - onChange={setAnte} - error={anteError} - setError={setAnteError} - disabled={isSubmitting} - /> - </div> - - <Spacer h={4} /> - - <div className="flex justify-end my-4"> - <button - type="submit" - className={clsx( - 'btn btn-primary', - isSubmitting && 'loading disabled' - )} - disabled={isSubmitting || !isValid} - onClick={(e) => { - e.preventDefault() - submit() - }} - > - {isSubmitting ? 'Creating...' : 'Create market'} - </button> - </div> - </form> - </div> + <div className="form-control"> + <label className="label"> + <span className="mb-1">Initial probability</span> + </label> + <Row className="items-center gap-2"> + <label className="input-group input-group-lg w-fit text-lg"> + <input + type="number" + value={initialProb} + className="input input-bordered input-md text-lg" + disabled={isSubmitting} + min={1} + max={99} + onChange={(e) => + setInitialProb(parseInt(e.target.value.substring(0, 2))) + } + /> + <span>%</span> + </label> + <input + type="range" + className="range range-primary" + min={1} + max={99} + value={initialProb} + onChange={(e) => setInitialProb(parseInt(e.target.value))} + /> + </Row> </div> - </Page> + + <Spacer h={4} /> + + <div className="form-control items-start mb-1"> + <label className="label gap-2 mb-1"> + <span className="mb-1">Description</span> + <InfoTooltip text="Optional. Describe how you will resolve this market." /> + </label> + <Textarea + className="textarea w-full textarea-bordered" + rows={3} + placeholder={descriptionPlaceholder} + value={description} + disabled={isSubmitting} + onClick={(e) => e.stopPropagation()} + onChange={(e) => setDescription(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + + <div className="form-control items-start mb-1"> + <label className="label gap-2 mb-1"> + <span>Last trading day</span> + <InfoTooltip text="Trading allowed through 11:59 pm local time on this date." /> + </label> + <input + type="date" + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setCloseDate(e.target.value || '')} + min={new Date().toISOString().split('T')[0]} + disabled={isSubmitting} + value={closeDate} + /> + </div> + + <Spacer h={4} /> + + <div className="form-control items-start mb-1"> + <label className="label gap-2 mb-1"> + <span>Market ante</span> + <InfoTooltip + text={`Subsidize your market to encourage trading. Ante bets are set to match your initial probability. + You earn ${CREATOR_FEE * 100}% of trading volume.`} + /> + </label> + <AmountInput + amount={ante} + minimumAmount={MINIMUM_ANTE} + onChange={setAnte} + error={anteError} + setError={setAnteError} + disabled={isSubmitting} + /> + </div> + + <Spacer h={4} /> + + <div className="flex justify-end my-4"> + <button + type="submit" + className={clsx( + 'btn btn-primary', + isSubmitting && 'loading disabled' + )} + disabled={isSubmitting || !isValid} + onClick={(e) => { + e.preventDefault() + submit() + }} + > + {isSubmitting ? 'Creating...' : 'Create market'} + </button> + </div> + </form> ) } diff --git a/web/pages/index.tsx b/web/pages/index.tsx index acf1eb2a..9bd5947b 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -2,13 +2,10 @@ import React from 'react' import _ from 'lodash' import { Contract, - getClosingSoonContracts, getHotContracts, listAllContracts, } from '../lib/firebase/contracts' -import { Spacer } from '../components/layout/spacer' import { Page } from '../components/page' -import { Title } from '../components/title' import { ActivityFeed, findActiveContracts } from './activity' import { getRecentComments, @@ -16,16 +13,17 @@ import { listAllComments, } from '../lib/firebase/comments' import { Bet, listAllBets } from '../lib/firebase/bets' -import { ContractsGrid } from '../components/contracts-list' +import FeedCreate, { FeedPromo } from '../components/feed-create' +import { Spacer } from '../components/layout/spacer' +import { Col } from '../components/layout/col' +import { useUser } from '../hooks/use-user' export async function getStaticProps() { - const [contracts, hotContracts, closingSoonContracts, recentComments] = - await Promise.all([ - listAllContracts().catch((_) => []), - getHotContracts().catch(() => []), - getClosingSoonContracts().catch(() => []), - getRecentComments().catch(() => []), - ]) + const [contracts, recentComments, hotContracts] = await Promise.all([ + listAllContracts().catch((_) => []), + getRecentComments().catch(() => []), + getHotContracts().catch(() => []), + ]) const activeContracts = findActiveContracts(contracts, recentComments) const activeContractBets = await Promise.all( @@ -41,7 +39,6 @@ export async function getStaticProps() { activeContractBets, activeContractComments, hotContracts, - closingSoonContracts, }, revalidate: 60, // regenerate after a minute @@ -53,53 +50,37 @@ const Home = (props: { activeContractBets: Bet[][] activeContractComments: Comment[][] hotContracts: Contract[] - closingSoonContracts: Contract[] }) => { const { activeContracts, activeContractBets, activeContractComments, hotContracts, - closingSoonContracts, } = props + const user = useUser() + return ( <Page> - <HotMarkets contracts={hotContracts} /> - <Spacer h={10} /> - <ClosingSoonMarkets contracts={closingSoonContracts} /> - <Spacer h={10} /> - <ActivityFeed - contracts={activeContracts} - contractBets={activeContractBets} - contractComments={activeContractComments} - /> + <Col className="items-center"> + <Col className="max-w-3xl"> + <div className="-mx-2 sm:mx-0"> + {user ? ( + <FeedCreate user={user} /> + ) : ( + <FeedPromo hotContracts={hotContracts} /> + )} + <Spacer h={4} /> + <ActivityFeed + contracts={activeContracts} + contractBets={activeContractBets} + contractComments={activeContractComments} + /> + </div> + </Col> + </Col> </Page> ) } -const HotMarkets = (props: { contracts: Contract[] }) => { - const { contracts } = props - if (contracts.length === 0) return <></> - - return ( - <div className="w-full bg-indigo-50 border-2 border-indigo-100 p-6 rounded-lg shadow-md"> - <Title className="mt-0" text="πŸ”₯ Markets" /> - <ContractsGrid contracts={contracts} showHotVolume /> - </div> - ) -} - -const ClosingSoonMarkets = (props: { contracts: Contract[] }) => { - const { contracts } = props - if (contracts.length === 0) return <></> - - return ( - <div className="w-full bg-green-50 border-2 border-green-100 p-6 rounded-lg shadow-md"> - <Title className="mt-0" text="⏰ Closing soon" /> - <ContractsGrid contracts={contracts} showCloseTime /> - </div> - ) -} - export default Home diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index a5fdd818..64531b79 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -1,25 +1,44 @@ import _ from 'lodash' -import { SearchableGrid } from '../components/contracts-list' +import { ContractsGrid, SearchableGrid } from '../components/contracts-list' +import { Spacer } from '../components/layout/spacer' import { Page } from '../components/page' import { SEO } from '../components/SEO' +import { Title } from '../components/title' import { useContracts } from '../hooks/use-contracts' import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params' -import { Contract, listAllContracts } from '../lib/firebase/contracts' +import { + Contract, + getClosingSoonContracts, + getHotContracts, + listAllContracts, +} from '../lib/firebase/contracts' export async function getStaticProps() { - const contracts = await listAllContracts().catch((_) => {}) + const [contracts, hotContracts, closingSoonContracts] = await Promise.all([ + listAllContracts().catch((_) => []), + getHotContracts().catch(() => []), + getClosingSoonContracts().catch(() => []), + ]) return { props: { contracts, + hotContracts, + closingSoonContracts, }, revalidate: 60, // regenerate after a minute } } -export default function Markets(props: { contracts: Contract[] }) { +// TODO: Rename endpoint to "Explore" +export default function Markets(props: { + contracts: Contract[] + hotContracts: Contract[] + closingSoonContracts: Contract[] +}) { const contracts = useContracts() ?? props.contracts + const { hotContracts, closingSoonContracts } = props const { query, setQuery, sort, setSort } = useQueryAndSortParams() return ( @@ -29,6 +48,11 @@ export default function Markets(props: { contracts: Contract[] }) { description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets." url="/markets" /> + <HotMarkets contracts={hotContracts} /> + <Spacer h={10} /> + <ClosingSoonMarkets contracts={closingSoonContracts} /> + <Spacer h={10} /> + <SearchableGrid contracts={contracts} query={query} @@ -39,3 +63,27 @@ export default function Markets(props: { contracts: Contract[] }) { </Page> ) } + +const HotMarkets = (props: { contracts: Contract[] }) => { + const { contracts } = props + if (contracts.length === 0) return <></> + + return ( + <div className="w-full bg-indigo-50 border-2 border-indigo-100 p-6 rounded-lg shadow-md"> + <Title className="!mt-0" text="πŸ”₯ Markets" /> + <ContractsGrid contracts={contracts} showHotVolume /> + </div> + ) +} + +const ClosingSoonMarkets = (props: { contracts: Contract[] }) => { + const { contracts } = props + if (contracts.length === 0) return <></> + + return ( + <div className="w-full bg-green-50 border-2 border-green-100 p-6 rounded-lg shadow-md"> + <Title className="!mt-0" text="⏰ Closing soon" /> + <ContractsGrid contracts={contracts} showCloseTime /> + </div> + ) +}