Merge branch 'main' into user-profile

This commit is contained in:
mantikoros 2022-01-28 18:22:52 -06:00
commit 7dcdfdaec6
49 changed files with 878 additions and 420 deletions

View File

@ -15,4 +15,6 @@ export type Fold = {
// Default: creatorIds: undefined, excludedCreatorIds: []
creatorIds?: string[]
excludedCreatorIds?: string[]
followCount: number
}

View File

@ -12,12 +12,15 @@ export function getNewContract(
description: string,
initialProb: number,
ante: number,
closeTime: number
closeTime: number,
extraTags: string[]
) {
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
calcStartPool(initialProb, ante)
const tags = parseTags(`${question} ${description}`)
const tags = parseTags(
`${extraTags.map((tag) => `#${tag}`).join(' ')} ${question} ${description}`
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const contract: Contract = {

View File

@ -21,7 +21,7 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if resource.data.creatorId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys()
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'tags', 'lowercaseTags']);
allow delete: if resource.data.creatorId == request.auth.uid;
}
@ -45,7 +45,12 @@ service cloud.firestore {
match /folds/{foldId} {
allow read;
allow update: if request.auth.uid == resource.data.curatorId;
allow update, delete: if request.auth.uid == resource.data.curatorId;
}
match /folds/{foldId}/followers/{userId} {
allow read;
allow write: if request.auth.uid == userId;
}
}
}

View File

@ -18,6 +18,7 @@ export const createContract = functions
initialProb: number
ante: number
closeTime: number
tags?: string[]
},
context
) => {
@ -27,7 +28,7 @@ export const createContract = functions
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
const { question, description, initialProb, ante, closeTime } = data
const { question, description, initialProb, ante, closeTime, tags } = data
if (!question || !initialProb)
return { status: 'error', message: 'Missing contract attributes' }
@ -65,7 +66,8 @@ export const createContract = functions
description,
initialProb,
ante,
closeTime
closeTime,
tags ?? []
)
if (ante) await chargeUser(creator.id, ante)

View File

@ -23,11 +23,17 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
const { name, about, tags } = data
let { name, about, tags } = data
if (!name || typeof name !== 'string')
return { status: 'error', message: 'Name must be a non-empty string' }
name = name.trim().slice(0, 140)
if (typeof about !== 'string')
return { status: 'error', message: 'About must be a string' }
about = about.trim().slice(0, 140)
if (!_.isArray(tags))
return { status: 'error', message: 'Tags must be an array of strings' }
@ -57,10 +63,13 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
contractIds: [],
excludedContractIds: [],
excludedCreatorIds: [],
followCount: 0,
}
await foldRef.create(fold)
await foldRef.collection('followers').doc(userId).set({ userId })
return { status: 'success', fold }
}
)

View File

@ -11,6 +11,8 @@ export * from './sell-bet'
export * from './create-contract'
export * from './create-user'
export * from './create-fold'
export * from './on-fold-follow'
export * from './on-fold-delete'
export * from './unsubscribe'
export * from './update-contract-metrics'
export * from './update-user-metrics'

View File

@ -0,0 +1,10 @@
import * as functions from 'firebase-functions'
export const onFoldDelete = functions.firestore
.document('folds/{foldId}')
.onDelete(async (change, context) => {
const snapshot = await change.ref.collection('followers').get()
// Delete followers sub-collection.
await Promise.all(snapshot.docs.map((doc) => doc.ref.delete()))
})

View File

@ -0,0 +1,17 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const firestore = admin.firestore()
export const onFoldFollow = functions.firestore
.document('folds/{foldId}/followers/{userId}')
.onWrite(async (change, context) => {
const { foldId } = context.params
const snapshot = await firestore
.collection(`folds/${foldId}/followers`)
.get()
const followCount = snapshot.size
await firestore.doc(`folds/${foldId}`).update({ followCount })
})

View File

@ -14,6 +14,8 @@ export function AmountInput(props: {
disabled?: boolean
className?: string
inputClassName?: string
// Needed to focus the amount input
inputRef?: React.MutableRefObject<any>
}) {
const {
amount,
@ -24,6 +26,7 @@ export function AmountInput(props: {
className,
inputClassName,
minimumAmount,
inputRef,
} = props
const user = useUser()
@ -56,6 +59,7 @@ export function AmountInput(props: {
error && 'input-error',
inputClassName
)}
ref={inputRef}
type="text"
placeholder="0"
maxLength={9}

34
web/components/avatar.tsx Normal file
View File

@ -0,0 +1,34 @@
import Router from 'next/router'
import clsx from 'clsx'
export function Avatar(props: {
username?: string
avatarUrl?: string
noLink?: boolean
}) {
const { username, avatarUrl, noLink } = props
const onClick =
noLink && username
? undefined
: (e: any) => {
e.stopPropagation()
Router.push(`/${username}`)
}
return (
<div className="rounded-full bg-gray-400 w-10 h-10">
<img
className={clsx(
'rounded-full bg-gray-400 flex items-center justify-center',
!noLink && 'cursor-pointer',
!avatarUrl && 'hidden'
)}
src={avatarUrl}
width={40}
height={40}
onClick={onClick}
alt={username}
/>
</div>
)
}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useUser } from '../hooks/use-user'
import { Contract } from '../../common/contract'
@ -26,18 +26,34 @@ import { AmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { OutcomeLabel } from './outcome-label'
export function BetPanel(props: { contract: Contract; className?: string }) {
// Focus helper from https://stackoverflow.com/a/54159564/1222351
function useFocus(): [React.RefObject<HTMLElement>, () => void] {
const htmlElRef = useRef<HTMLElement>(null)
const setFocus = () => {
htmlElRef.current && htmlElRef.current.focus()
}
return [htmlElRef, setFocus]
}
export function BetPanel(props: {
contract: Contract
className?: string
title?: string // Set if BetPanel is on a feed modal
selected?: 'YES' | 'NO'
}) {
useEffect(() => {
// warm up cloud function
placeBet({}).catch()
}, [])
const { contract, className } = props
const { contract, className, title, selected } = props
const user = useUser()
const [betChoice, setBetChoice] = useState<'YES' | 'NO'>('YES')
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [inputRef, focusAmountInput] = useFocus()
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
@ -46,11 +62,15 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
function onBetChoice(choice: 'YES' | 'NO') {
setBetChoice(choice)
setWasSubmitted(false)
focusAmountInput()
}
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
if (!betChoice) {
setBetChoice('YES')
}
}
async function submitBet() {
@ -88,14 +108,14 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
const resultProb = getProbabilityAfterBet(
contract.totalShares,
betChoice,
betChoice || 'YES',
betAmount ?? 0
)
const shares = calculateShares(
contract.totalShares,
betAmount ?? 0,
betChoice
betChoice || 'YES'
)
const currentPayout = betAmount
@ -108,19 +128,21 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
const panelTitle = title ?? 'Place a trade'
if (title) {
focusAmountInput()
}
return (
<Col
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
>
<Col className={clsx('bg-white px-8 py-6 rounded-md', className)}>
<Title
className="mt-0 whitespace-nowrap text-neutral"
text={`Buy ${betChoice}`}
className={clsx('!mt-0', title ? '!text-xl' : '')}
text={panelTitle}
/>
<div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div>
{/* <div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> */}
<YesNoSelector
className="my-2"
className="mb-4"
selected={betChoice}
onSelect={(choice) => onBetChoice(choice)}
/>
@ -133,6 +155,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
error={error}
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
/>
<Spacer h={4} />
@ -144,22 +167,27 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
<div>{formatPercent(resultProb)}</div>
</Row>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
Payout if <OutcomeLabel outcome={betChoice} />
<InfoTooltip
text={`Current payout for ${formatWithCommas(
shares
)} / ${formatWithCommas(
shares +
contract.totalShares[betChoice] -
contract.phantomShares[betChoice]
)} ${betChoice} shares`}
/>
</Row>
<div>
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
{betChoice && (
<>
<Spacer h={4} />
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
Payout if <OutcomeLabel outcome={betChoice} />
<InfoTooltip
text={`Current payout for ${formatWithCommas(
shares
)} / ${formatWithCommas(
shares +
contract.totalShares[betChoice] -
contract.phantomShares[betChoice]
)} ${betChoice} shares`}
/>
</Row>
<div>
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
</>
)}
<Spacer h={6} />

100
web/components/bet-row.tsx Normal file
View File

@ -0,0 +1,100 @@
import clsx from 'clsx'
import { Fragment, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { Contract } from '../lib/firebase/contracts'
import { BetPanel } from './bet-panel'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
// Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: {
contract: Contract
className?: string
labelClassName?: string
}) {
const { className, labelClassName } = props
const [open, setOpen] = useState(false)
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(
undefined
)
return (
<>
<div className={className}>
<Row className="items-center gap-2 justify-end">
<div className={clsx('text-gray-400 mr-2', labelClassName)}>
Place a trade
</div>
<YesNoSelector
btnClassName="btn-sm w-20"
onSelect={(choice) => {
setOpen(true)
setBetChoice(choice)
}}
/>
</Row>
<Modal open={open} setOpen={setOpen}>
<BetPanel
contract={props.contract}
title={props.contract.question}
selected={betChoice}
/>
</Modal>
</div>
</>
)
}
// From https://tailwindui.com/components/application-ui/overlays/modals
export function Modal(props: {
children: React.ReactNode
open: boolean
setOpen: (open: boolean) => void
}) {
const { children, open, setOpen } = props
return (
<Transition.Root show={open} as={Fragment}>
<Dialog
as="div"
className="fixed z-40 inset-0 overflow-y-auto"
onClose={setOpen}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6">
{children}
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
)
}

View File

@ -94,7 +94,7 @@ export function BetsList(props: { user: User }) {
return (
<Col className="mt-6 gap-6">
<Row className="gap-8">
<Row className="mx-4 md:mx-0 gap-8">
<Col>
<div className="text-sm text-gray-500">Currently invested</div>
<div>{formatMoney(currentBets)}</div>

View File

@ -13,7 +13,6 @@ import { parseTags } from '../../common/util/parse'
import dayjs from 'dayjs'
import { TrendingUpIcon } from '@heroicons/react/solid'
import { DateTimeTooltip } from './datetime-tooltip'
import { CompactTagsList } from './tags-list'
import { ClockIcon } from '@heroicons/react/outline'
import { fromNow } from '../lib/util/time'
@ -194,13 +193,6 @@ export function ContractDetails(props: { contract: Contract }) {
<div className=""></div>
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
</Row>
{tags.length > 0 && (
<>
<div className="hidden sm:block"></div>
<CompactTagsList tags={tags} />
</>
)}
</Col>
)
}

View File

@ -11,14 +11,16 @@ import {
UsersIcon,
XIcon,
} from '@heroicons/react/solid'
import dayjs from 'dayjs'
import clsx from 'clsx'
import { OutcomeLabel } from './outcome-label'
import {
contractMetrics,
Contract,
contractPath,
updateContract,
tradingAllowed,
} from '../lib/firebase/contracts'
import { useUser } from '../hooks/use-user'
import { Linkify } from './linkify'
@ -38,22 +40,9 @@ import { JoinSpans } from './join-spans'
import Textarea from 'react-expanding-textarea'
import { outcome } from '../../common/contract'
import { fromNow } from '../lib/util/time'
import BetRow from './bet-row'
import { parseTags } from '../../common/util/parse'
export function AvatarWithIcon(props: { username: string; avatarUrl: string }) {
const { username, avatarUrl } = props
return (
<SiteLink className="relative" href={`/${username}`}>
<img
className="rounded-full bg-gray-400 flex items-center justify-center"
src={avatarUrl}
width={40}
height={40}
alt=""
/>
</SiteLink>
)
}
import { Avatar } from './avatar'
function FeedComment(props: {
activityItem: any
@ -68,7 +57,7 @@ function FeedComment(props: {
return (
<>
<AvatarWithIcon username={person.username} avatarUrl={person.avatarUrl} />
<Avatar username={person.username} avatarUrl={person.avatarUrl} />
<div className="min-w-0 flex-1">
<div>
<p className="mt-0.5 text-sm text-gray-500">
@ -283,7 +272,7 @@ function FeedQuestion(props: { contract: Contract }) {
return (
<>
{contract.creatorAvatarUrl ? (
<AvatarWithIcon
<Avatar
username={contract.creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
/>
@ -337,7 +326,7 @@ function FeedDescription(props: { contract: Contract }) {
return (
<>
{contract.creatorAvatarUrl ? (
<AvatarWithIcon
<Avatar
username={contract.creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
/>
@ -619,8 +608,9 @@ export function ContractFeed(props: {
comments: Comment[]
// Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market
feedType: 'activity' | 'market'
betRowClassName?: string
}) {
const { contract, feedType } = props
const { contract, feedType, betRowClassName } = props
const { id } = contract
const [expanded, setExpanded] = useState(false)
const user = useUser()
@ -654,8 +644,8 @@ export function ContractFeed(props: {
}
return (
<div className="flow-root">
<ul role="list" className="-mb-8">
<div className="flow-root pr-2 md:pr-0">
<ul role="list" className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
{items.map((activityItem, activityItemIdx) => (
<li key={activityItem.id}>
<div className="relative pb-8">
@ -694,6 +684,12 @@ export function ContractFeed(props: {
</li>
))}
</ul>
{tradingAllowed(contract) && (
<BetRow
contract={contract}
className={clsx('-mt-4', betRowClassName)}
/>
)}
</div>
)
}

View File

@ -17,6 +17,8 @@ import { ContractFeed } from './contract-feed'
import { TweetButton } from './tweet-button'
import { Bet } from '../../common/bet'
import { Comment } from '../../common/comment'
import { TagsInput } from './tags-input'
import BetRow from './bet-row'
export const ContractOverview = (props: {
contract: Contract
@ -35,9 +37,7 @@ export const ContractOverview = (props: {
? contract.question
: `${creatorName}: ${contract.question}`
const tweetDescription = resolution
? isCreator
? `Resolved ${resolution}!`
: `Resolved ${resolution} by ${creatorName}:`
? `Resolved ${resolution}!`
: `Currently ${probPercent} chance, place your bets here:`
const url = `https://manifold.markets${contractPath(contract)}`
const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
@ -50,15 +50,22 @@ export const ContractOverview = (props: {
<Linkify text={contract.question} />
</div>
<ResolutionOrChance
className="md:hidden"
resolution={resolution}
probPercent={probPercent}
large
/>
<Row className="justify-between items-center gap-4">
<ResolutionOrChance
className="md:hidden"
resolution={resolution}
probPercent={probPercent}
large
/>
<BetRow
contract={contract}
className="md:hidden"
labelClassName="hidden"
/>
</Row>
<ContractDetails contract={contract} />
<TweetButton className="self-end md:hidden" tweetText={tweetText} />
</Col>
<Col className="hidden md:flex justify-between items-end">
@ -68,7 +75,6 @@ export const ContractOverview = (props: {
probPercent={probPercent}
large
/>
<TweetButton className="mt-6" tweetText={tweetText} />
</Col>
</Row>
@ -76,6 +82,11 @@ export const ContractOverview = (props: {
<ContractProbGraph contract={contract} />
<Row className="justify-between mt-6 ml-4 gap-4">
<TagsInput contract={contract} />
<TweetButton tweetText={tweetText} />
</Row>
<Spacer h={12} />
{/* Show a delete button for contracts without any trading */}
@ -100,6 +111,7 @@ export const ContractOverview = (props: {
bets={bets}
comments={comments}
feedType="market"
betRowClassName="md:hidden !mt-0"
/>
</Col>
)

View File

@ -27,7 +27,7 @@ export function ContractProbGraph(props: { contract: Contract }) {
if (!resolutionTime) {
// Add a fake datapoint in future so the line continues horizontally
// to the right.
times.push(latestTime.add(1, 'day').toDate())
times.push(latestTime.add(1, 'month').toDate())
probs.push(probs[probs.length - 1])
}

View File

@ -33,14 +33,8 @@ export function ContractsGrid(props: {
if (contracts.length === 0) {
return (
<p className="mx-4">
No markets found. Would you like to{' '}
<Link href="/create">
<a className="text-green-500 hover:underline hover:decoration-2">
create one
</a>
</Link>
?
<p className="text-gray-500 mx-2">
No markets found. Why not create one?
</p>
)
}

View File

@ -57,15 +57,16 @@ export function CreateFoldButton() {
}}
submitBtn={{
label: 'Create',
className: clsx(name && about ? 'btn-primary' : 'btn-disabled'),
className: clsx(name ? 'btn-primary' : 'btn-disabled'),
}}
onSubmit={onSubmit}
>
<Title className="!mt-0" text="Create a fold" />
<Col className="text-gray-500 gap-1">
<div>A fold is a sub-community of markets organized on a topic.</div>
<div>Markets are included if they match one or more tags.</div>
<div>
Markets are included in a fold if they match one or more tags.
</div>
</Col>
<Spacer h={4} />
@ -81,6 +82,7 @@ export function CreateFoldButton() {
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={140}
onChange={(e) => updateName(e.target.value || '')}
/>
</div>
@ -93,7 +95,7 @@ export function CreateFoldButton() {
</label>
<input
placeholder="Short description (140 characters max)"
placeholder="Short description (140 characters max, optional)"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={about}

View File

@ -5,13 +5,16 @@ import { PencilIcon } from '@heroicons/react/outline'
import { Fold } from '../../common/fold'
import { parseWordsAsTags } from '../../common/util/parse'
import { updateFold } from '../lib/firebase/folds'
import { deleteFold, updateFold } from '../lib/firebase/folds'
import { toCamelCase } from '../lib/util/format'
import { Spacer } from './layout/spacer'
import { TagsList } from './tags-list'
import { useRouter } from 'next/router'
export function EditFoldButton(props: { fold: Fold; className?: string }) {
const { fold, className } = props
const router = useRouter()
export function EditFoldButton(props: { fold: Fold }) {
const { fold } = props
const [name, setName] = useState(fold.name)
const [about, setAbout] = useState(fold.about ?? '')
@ -41,7 +44,7 @@ export function EditFoldButton(props: { fold: Fold }) {
}
return (
<div>
<div className={clsx('p-1', className)}>
<label
htmlFor="edit"
className={clsx(
@ -106,6 +109,20 @@ export function EditFoldButton(props: { fold: Fold }) {
<Spacer h={4} />
<div className="modal-action">
<label
htmlFor="edit"
onClick={() => {
if (confirm('Are you sure you want to delete this fold?')) {
deleteFold(fold)
router.replace('/folds')
}
}}
className={clsx(
'btn btn-sm btn-outline hover:bg-red-500 hover:border-red-500 mr-auto self-center'
)}
>
Delete
</label>
<label htmlFor="edit" className={clsx('btn')}>
Cancel
</label>

View File

@ -1,104 +1,117 @@
import { AvatarWithIcon } from './contract-feed'
import { Title } from './title'
import Textarea from 'react-expanding-textarea'
import { useState } from 'react'
import { Avatar } from './avatar'
import { useRef, useState } from 'react'
import { Spacer } from './layout/spacer'
import { NewContract } from '../pages/create'
import { firebaseLogin, User } from '../lib/firebase/users'
import { ContractsGrid } from './contracts-list'
import { Contract } from '../../common/contract'
import { TagsList } from './tags-list'
import { Col } from './layout/col'
import clsx from 'clsx'
export function FeedPromo(props: { hotContracts: Contract[] }) {
const { hotContracts } = props
return (
<>
<div className="w-full bg-indigo-50 p-6 sm:border-2 sm:border-indigo-100 sm:rounded-lg">
<Title
text="Bet on the future"
className="!mt-2 text-gray-800 !text-4xl"
/>
<Col className="w-full bg-white p-6 sm:rounded-lg">
<h1 className="mt-4 text-4xl sm:mt-5 sm:text-6xl lg:mt-6 xl:text-6xl">
<div className="mb-2">Create your own</div>
<div className="font-bold bg-clip-text text-transparent bg-gradient-to-r from-teal-400 to-green-400">
prediction markets
</div>
</h1>
<Spacer h={6} />
<div className="text-gray-500 mb-4">
On Manifold Markets, you can find prediction markets run by your
favorite creators.
Find prediction markets run by your favorite creators, or make your
own.
<br />
<button
className="text-green-500 hover:underline hover:decoration-gray-300 hover:decoration-2"
onClick={firebaseLogin}
>
Sign up to get M$ 1000 for free
</button>{' '}
and start trading!
Sign up to get M$ 1,000 for free and start trading!
<br />
</div>
<Spacer h={6} />
<button
className="btn btn-lg self-center border-none bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up now
</button>{' '}
</Col>
<TagsList
className="mt-2"
tags={['#politics', '#crypto', '#covid', '#sports', '#meta']}
/>
<Spacer h={6} />
{/*
<TagsList
className="mt-2"
tags={['#politics', '#crypto', '#covid', '#sports', '#meta']}
/>
<Spacer h={6} /> */}
<Spacer h={4} />
<ContractsGrid
contracts={hotContracts?.slice(0, 6) || []}
showHotVolume
/>
</div>
<ContractsGrid
contracts={hotContracts?.slice(0, 10) || []}
showHotVolume
/>
</>
)
}
export default function FeedCreate(props: { user?: User }) {
const { user } = props
export default function FeedCreate(props: {
user?: User
tag?: string
placeholder?: string
className?: string
}) {
const { user, tag, className } = 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?',
'Will any cryptocurrency eclipse Bitcoin by market cap this year?',
'Will the Democrats win the 2024 presidential election?',
]
// 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]
// const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24)
const [randIndex] = useState(
Math.floor(Math.random() * 1e10) % placeholders.length
)
const placeholder = props.placeholder ?? `e.g. ${placeholders[randIndex]}`
const inputRef = useRef<HTMLTextAreaElement | null>()
return (
<div className="w-full bg-indigo-50 sm:rounded-md p-4">
<div
className={clsx('w-full bg-white p-4 border-b-2 mt-2', className)}
onClick={() => !question && inputRef.current?.focus()}
>
<div className="relative flex items-start space-x-3">
<AvatarWithIcon
username={user?.username || ''}
avatarUrl={user?.avatarUrl || ''}
/>
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} noLink />
<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
<textarea
ref={inputRef as any}
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:text-gray-400"
placeholder={`e.g. ${placeholder}`}
placeholder={placeholder}
value={question}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setQuestion(e.target.value || '')}
onChange={(e) => setQuestion(e.target.value.replace('\n', ''))}
/>
<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} />
<NewContract question={question} tag={tag} />
</div>
{/* Show a fake "Create Market" button, which gets replaced with the NewContract one*/}
{!question && (
<div className="flex justify-end">
<button className="btn" disabled>
<button className="btn btn-sm" disabled>
Create Market
</button>
</div>

View File

@ -0,0 +1,47 @@
import clsx from 'clsx'
import { Fold } from '../../common/fold'
import { useFollowingFold } from '../hooks/use-fold'
import { useUser } from '../hooks/use-user'
import { followFold, unfollowFold } from '../lib/firebase/folds'
export function FollowFoldButton(props: { fold: Fold; className?: string }) {
const { fold, className } = props
const user = useUser()
const following = useFollowingFold(fold, user)
const onFollow = () => {
if (user) followFold(fold, user)
}
const onUnfollow = () => {
if (user) unfollowFold(fold, user)
}
if (!user || following === undefined)
return (
<button className={clsx('btn btn-sm invisible', className)}>
Follow
</button>
)
if (following) {
return (
<button
className={clsx('btn btn-outline btn-sm', className)}
onClick={onUnfollow}
>
Following
</button>
)
}
return (
<button
className={clsx('btn btn-secondary bg-indigo-500 btn-sm', className)}
onClick={onFollow}
>
Follow
</button>
)
}

View File

@ -17,43 +17,47 @@ export function Leaderboard(props: {
return (
<div className={clsx('w-full px-1', className)}>
<Title text={title} />
<div className="overflow-x-auto">
<table className="table table-zebra table-compact text-gray-500 w-full">
<thead>
<tr className="p-2">
<th>#</th>
<th>Name</th>
{columns.map((column) => (
<th key={column.header}>{column.header}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={user.id}>
<td>{index + 1}</td>
<td>
<SiteLink className="relative" href={`/${user.username}`}>
<Row className="items-center gap-4">
<img
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
src={user.avatarUrl || ''}
alt=""
width={32}
height={32}
/>
<div className="truncate">{user.name}</div>
</Row>
</SiteLink>
</td>
{users.length === 0 ? (
<div className="text-gray-500 ml-2">None yet</div>
) : (
<div className="overflow-x-auto">
<table className="table table-zebra table-compact text-gray-500 w-full">
<thead>
<tr className="p-2">
<th>#</th>
<th>Name</th>
{columns.map((column) => (
<td key={column.header}>{column.renderCell(user)}</td>
<th key={column.header}>{column.header}</th>
))}
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={user.id}>
<td>{index + 1}</td>
<td>
<SiteLink className="relative" href={`/${user.username}`}>
<Row className="items-center gap-4">
<img
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
src={user.avatarUrl || ''}
alt=""
width={32}
height={32}
/>
<div className="truncate">{user.name}</div>
</Row>
</SiteLink>
</td>
{columns.map((column) => (
<td key={column.header}>{column.renderCell(user)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -1,5 +1,4 @@
import Link from 'next/link'
import Image from 'next/image'
import clsx from 'clsx'
import { useUser } from '../hooks/use-user'
@ -15,7 +14,7 @@ export function ManifoldLogo(props: {
return (
<Link href={user ? '/home' : '/'}>
<a className={clsx('flex flex-row gap-4 flex-shrink-0', className)}>
<Image
<img
className="hover:rotate-12 transition-all"
src={darkBackground ? '/logo-white.svg' : '/logo.svg'}
width={45}

View File

@ -9,7 +9,7 @@ export function MenuButton(props: {
}) {
const { buttonContent, menuItems, className } = props
return (
<Menu as="div" className={clsx('flex-shrink-0 relative z-10', className)}>
<Menu as="div" className={clsx('flex-shrink-0 relative z-50', className)}>
<div>
<Menu.Button className="rounded-full flex">
<span className="sr-only">Open user menu</span>

View File

@ -10,9 +10,10 @@ import { ProfileMenu } from './profile-menu'
export function NavBar(props: {
darkBackground?: boolean
wide?: boolean
assertUser?: 'signed-in' | 'signed-out'
className?: string
}) {
const { darkBackground, wide, className } = props
const { darkBackground, wide, assertUser, className } = props
const user = useUser()
@ -31,8 +32,12 @@ export function NavBar(props: {
<ManifoldLogo className="my-1" darkBackground={darkBackground} />
<Row className="items-center gap-6 sm:gap-8 ml-6">
{(user || user === null) && (
<NavOptions user={user} themeClasses={themeClasses} />
{(user || user === null || assertUser) && (
<NavOptions
user={user}
assertUser={assertUser}
themeClasses={themeClasses}
/>
)}
</Row>
</Row>
@ -40,11 +45,19 @@ export function NavBar(props: {
)
}
function NavOptions(props: { user: User | null; themeClasses: string }) {
const { user, themeClasses } = props
function NavOptions(props: {
user: User | null | undefined
assertUser: 'signed-in' | 'signed-out' | undefined
themeClasses: string
}) {
const { user, assertUser, themeClasses } = props
const showSignedIn = assertUser === 'signed-in' || !!user
const showSignedOut =
!showSignedIn && (assertUser === 'signed-out' || user === null)
return (
<>
{user === null && (
{showSignedOut && (
<Link href="/about">
<a
className={clsx(
@ -57,7 +70,7 @@ function NavOptions(props: { user: User | null; themeClasses: string }) {
</Link>
)}
{/* <Link href="/folds">
<Link href="/folds">
<a
className={clsx(
'text-base hidden md:block whitespace-nowrap',
@ -66,7 +79,7 @@ function NavOptions(props: { user: User | null; themeClasses: string }) {
>
Folds
</a>
</Link> */}
</Link>
<Link href="/markets">
<a
@ -75,35 +88,21 @@ function NavOptions(props: { user: User | null; themeClasses: string }) {
themeClasses
)}
>
All markets
Markets
</a>
</Link>
{user === null ? (
{showSignedOut && (
<>
<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"
className="btn btn-sm btn-outline normal-case text-base font-medium px-6 bg-gradient-to-r"
onClick={firebaseLogin}
>
Sign in
</button>
</>
) : (
<>
<Link href="/leaderboards">
<a
className={clsx(
'text-base hidden md:block whitespace-nowrap',
themeClasses
)}
>
Leaderboards
</a>
</Link>
<ProfileMenu user={user} />
</>
)}
{showSignedIn && <ProfileMenu user={user ?? undefined} />}
</>
)
}

View File

@ -1,17 +1,23 @@
import clsx from 'clsx'
import { NavBar } from './nav-bar'
export function Page(props: { wide?: boolean; children?: any }) {
const { wide, children } = props
export function Page(props: {
wide?: boolean
margin?: boolean
assertUser?: 'signed-in' | 'signed-out'
children?: any
}) {
const { wide, margin, assertUser, children } = props
return (
<div>
<NavBar wide={wide} />
<NavBar wide={wide} assertUser={assertUser} />
<div
className={clsx(
'w-full px-2 pb-8 mx-auto',
wide ? 'max-w-6xl' : 'max-w-4xl'
'w-full mx-auto',
wide ? 'max-w-6xl' : 'max-w-4xl',
margin && 'px-4'
)}
>
{children}

View File

@ -1,10 +1,10 @@
import Image from 'next/image'
import { firebaseLogout, User } from '../lib/firebase/users'
import { formatMoney } from '../lib/util/format'
import { Avatar } from './avatar'
import { Col } from './layout/col'
import { MenuButton } from './menu'
export function ProfileMenu(props: { user: User }) {
export function ProfileMenu(props: { user: User | undefined }) {
const { user } = props
return (
@ -24,12 +24,15 @@ export function ProfileMenu(props: { user: User }) {
)
}
function getNavigationOptions(user: User, options: { mobile: boolean }) {
function getNavigationOptions(
user: User | undefined,
options: { mobile: boolean }
) {
const { mobile } = options
return [
{
name: 'Home',
href: '/',
href: user ? '/home' : '/',
},
{
name: 'Profile',
@ -38,9 +41,13 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) {
...(mobile
? [
{
name: 'All markets',
name: 'Markets',
href: '/markets',
},
{
name: 'Folds',
href: '/folds',
},
]
: []),
{
@ -49,7 +56,11 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) {
},
{
name: 'Your markets',
href: `/${user.username}`,
href: `/${user?.username ?? ''}`,
},
{
name: 'Leaderboards',
href: '/leaderboards',
},
{
name: 'Discord',
@ -67,17 +78,16 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) {
]
}
function ProfileSummary(props: { user: User }) {
function ProfileSummary(props: { user: User | undefined }) {
const { user } = props
return (
<Col className="avatar items-center sm:flex-row gap-2 sm:gap-0">
<div className="rounded-full w-10 h-10 sm:mr-4">
{user.avatarUrl && <img src={user.avatarUrl} width={40} height={40} />}
</div>
<div className="truncate text-left" style={{ maxWidth: 170 }}>
<div className="hidden sm:flex">{user.name}</div>
<Col className="avatar items-center sm:flex-row gap-2 sm:gap-4">
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} noLink />
<div className="truncate text-left sm:w-32">
<div className="hidden sm:flex">{user?.name}</div>
<div className="text-gray-700 text-sm">
{formatMoney(Math.floor(user.balance))}
{user ? formatMoney(Math.floor(user.balance)) : ' '}
</div>
</div>
</Col>

View File

@ -57,10 +57,8 @@ export function ResolutionPanel(props: {
: 'btn-disabled'
return (
<Col
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
>
<Title className="mt-0 text-neutral" text="Your market" />
<Col className={clsx('bg-white px-8 py-6 rounded-md', className)}>
<Title className="mt-0" text="Your market" />
<div className="pt-2 pb-1 text-sm text-gray-500">Resolve outcome</div>

View File

@ -12,7 +12,7 @@ export const SiteLink = (props: {
<a
href={href}
className={clsx(
'hover:underline hover:decoration-indigo-400 hover:decoration-2',
'hover:underline hover:decoration-indigo-400 hover:decoration-2 z-10',
className
)}
onClick={(e) => e.stopPropagation()}
@ -23,7 +23,7 @@ export const SiteLink = (props: {
<Link href={href}>
<a
className={clsx(
'hover:underline hover:decoration-indigo-400 hover:decoration-2',
'hover:underline hover:decoration-indigo-400 hover:decoration-2 z-10',
className
)}
onClick={(e) => e.stopPropagation()}

View File

@ -0,0 +1,45 @@
import { useState } from 'react'
import { parseWordsAsTags } from '../../common/util/parse'
import { Contract, updateContract } from '../lib/firebase/contracts'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
export function TagsInput(props: { contract: Contract }) {
const { contract } = props
const { tags } = contract
const [tagText, setTagText] = useState('')
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
const [isSubmitting, setIsSubmitting] = useState(false)
const updateTags = () => {
setIsSubmitting(true)
updateContract(contract.id, {
tags: newTags,
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
})
setIsSubmitting(false)
setTagText('')
}
return (
<Row className="flex-wrap gap-4">
<TagsList tags={newTags.map((tag) => `#${tag}`)} />
<Row className="items-center gap-4">
<input
style={{ maxWidth: 150 }}
placeholder="Type a tag..."
className="input input-sm input-bordered resize-none"
disabled={isSubmitting}
value={tagText}
onChange={(e) => setTagText(e.target.value || '')}
/>
<button className="btn btn-xs btn-outline" onClick={updateTags}>
Save tags
</button>
</Row>
</Row>
)
}

View File

@ -37,7 +37,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
url={`/@${user.username}`}
/>
<Title text={possesive + 'markets'} />
<Title className="mx-4 md:mx-0" text={possesive + 'markets'} />
<CreatorContractsList creator={user} />
</Page>

View File

@ -5,27 +5,39 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
export function YesNoSelector(props: {
selected: 'YES' | 'NO'
selected?: 'YES' | 'NO'
onSelect: (selected: 'YES' | 'NO') => void
className?: string
btnClassName?: string
}) {
const { selected, onSelect, className } = props
const { selected, onSelect, className, btnClassName } = props
return (
<Row className={clsx('space-x-3', className)}>
<Button
color={selected === 'YES' ? 'green' : 'gray'}
<button
className={clsx(
'flex-1 inline-flex justify-center items-center p-2 hover:bg-primary-focus hover:text-white rounded-lg border-primary hover:border-primary-focus border-2',
selected == 'YES'
? 'bg-primary text-white'
: 'bg-transparent text-primary',
btnClassName
)}
onClick={() => onSelect('YES')}
>
YES
</Button>
<Button
color={selected === 'NO' ? 'red' : 'gray'}
Buy YES
</button>
<button
className={clsx(
'flex-1 inline-flex justify-center items-center p-2 hover:bg-red-500 hover:text-white rounded-lg border-red-400 hover:border-red-500 border-2',
selected == 'NO'
? 'bg-red-400 text-white'
: 'bg-transparent text-red-400',
btnClassName
)}
onClick={() => onSelect('NO')}
>
NO
</Button>
Buy NO
</button>
</Row>
)
}

View File

@ -1,12 +1,17 @@
import { useEffect, useState } from 'react'
import { Fold } from '../../common/fold'
import { listenForFold, listenForFolds } from '../lib/firebase/folds'
import { User } from '../../common/user'
import {
listenForFold,
listenForFolds,
listenForFollow,
} from '../lib/firebase/folds'
export const useFold = (foldId: string) => {
export const useFold = (foldId: string | undefined) => {
const [fold, setFold] = useState<Fold | null | undefined>()
useEffect(() => {
return listenForFold(foldId, setFold)
if (foldId) return listenForFold(foldId, setFold)
}, [foldId])
return fold
@ -21,3 +26,13 @@ export const useFolds = () => {
return folds
}
export const useFollowingFold = (fold: Fold, user: User | null | undefined) => {
const [following, setFollowing] = useState<boolean | undefined>()
useEffect(() => {
if (user) return listenForFollow(fold, user, setFollowing)
}, [fold, user])
return following
}

View File

@ -52,6 +52,13 @@ export function contractMetrics(contract: Contract) {
return { truePool, probPercent, startProb, createdDate, resolvedDate }
}
export function tradingAllowed(contract: Contract) {
return (
!contract.isResolved &&
(!contract.closeTime || contract.closeTime > Date.now())
)
}
const db = getFirestore(app)
export const contractCollection = collection(db, 'contracts')
@ -153,7 +160,7 @@ export function listenForHotContracts(
export async function getHotContracts() {
const contracts = await getValues<Contract>(hotContractsQuery)
return _.sortBy(
chooseRandomSubset(contracts, 6),
chooseRandomSubset(contracts, 10),
(contract) => -1 * contract.volume24Hours
)
}

View File

@ -1,7 +1,16 @@
import { collection, doc, query, updateDoc, where } from 'firebase/firestore'
import {
collection,
deleteDoc,
doc,
query,
setDoc,
updateDoc,
where,
} from 'firebase/firestore'
import { Fold } from '../../../common/fold'
import { Contract, contractCollection } from './contracts'
import { db } from './init'
import { User } from './users'
import { getValues, listenForValue, listenForValues } from './utils'
const foldCollection = collection(db, 'folds')
@ -17,6 +26,10 @@ export function updateFold(fold: Fold, updates: Partial<Fold>) {
return updateDoc(doc(foldCollection, fold.id), updates)
}
export function deleteFold(fold: Fold) {
return deleteDoc(doc(foldCollection, fold.id))
}
export async function listAllFolds() {
return getValues<Fold>(foldCollection)
}
@ -90,3 +103,24 @@ export function listenForFold(
) {
return listenForValue(doc(foldCollection, foldId), setFold)
}
export function followFold(fold: Fold, user: User) {
const followDoc = doc(foldCollection, fold.id, 'followers', user.id)
return setDoc(followDoc, { userId: user.id })
}
export function unfollowFold(fold: Fold, user: User) {
const followDoc = doc(foldCollection, fold.id, 'followers', user.id)
return deleteDoc(followDoc)
}
export function listenForFollow(
fold: Fold,
user: User,
setFollow: (following: boolean) => void
) {
const followDoc = doc(foldCollection, fold.id, 'followers', user.id)
return listenForValue(followDoc, (value) => {
setFollow(!!value)
})
}

View File

@ -18,7 +18,7 @@ export function formatPercent(zeroToOne: number) {
}
export function toCamelCase(words: string) {
return words
const camelCase = words
.split(' ')
.map((word) => word.trim())
.filter((word) => word)
@ -26,4 +26,8 @@ export function toCamelCase(words: string) {
index === 0 ? word : word[0].toLocaleUpperCase() + word.substring(1)
)
.join('')
// Remove non-alpha-numeric-underscore chars.
const regex = /(?:^|\s)(?:[a-z0-9_]+)/gi
return (camelCase.match(regex) || [])[0] ?? ''
}

View File

@ -15,6 +15,7 @@ import {
contractMetrics,
Contract,
getContractFromSlug,
tradingAllowed,
} from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO'
import { Page } from '../../components/page'
@ -70,8 +71,7 @@ export default function ContractPage(props: {
const { creatorId, isResolved, resolution, question } = contract
const isCreator = user?.id === creatorId
const allowTrade =
!isResolved && (!contract.closeTime || contract.closeTime > Date.now())
const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user
const { probPercent } = contractMetrics(contract)
@ -97,8 +97,8 @@ export default function ContractPage(props: {
ogCardProps={ogCardProps}
/>
<Col className="w-full md:flex-row justify-between mt-6">
<div className="flex-[3]">
<Col className="w-full md:flex-row justify-between">
<div className="flex-[3] bg-white px-2 py-6 md:px-6 md:py-8 rounded border-0 border-gray-100">
<ContractOverview
contract={contract}
bets={bets ?? []}
@ -109,10 +109,12 @@ export default function ContractPage(props: {
{(allowTrade || allowResolve) && (
<>
<div className="md:ml-8" />
<div className="md:ml-6" />
<Col className="flex-1">
{allowTrade && <BetPanel contract={contract} />}
{allowTrade && (
<BetPanel className="hidden lg:inline" contract={contract} />
)}
{allowResolve && (
<ResolutionPanel creator={user} contract={contract} />
)}

View File

@ -5,7 +5,7 @@ import styles from './about.module.css'
export default function About() {
return (
<Page>
<Page margin>
<SEO title="About" description="About" url="/about" />
<Contents />
</Page>

View File

@ -5,8 +5,6 @@ import { Title } from '../components/title'
import { FundsSelector } from '../components/yes-no-selector'
import { useUser } from '../hooks/use-user'
import { checkoutURL } from '../lib/service/stripe'
import Image from 'next/image'
import { Spacer } from '../components/layout/spacer'
import { Page } from '../components/page'
export default function AddFundsPage() {
@ -23,7 +21,7 @@ export default function AddFundsPage() {
<Col className="items-center">
<Col>
<Title text="Get Manifold Dollars" />
<Image
<img
className="block mt-6"
src="/praying-mantis-light.svg"
width={200}

View File

@ -47,8 +47,8 @@ export default function Create() {
}
// Allow user to create a new contract
export function NewContract(props: { question: string }) {
const question = props.question
export function NewContract(props: { question: string; tag?: string }) {
const { question, tag } = props
const creator = useUser()
useEffect(() => {
@ -72,12 +72,12 @@ export function NewContract(props: { question: string }) {
const [anteError, setAnteError] = useState<string | undefined>()
// 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 weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD')
const [closeDate, setCloseDate] = useState<undefined | string>(undefined)
const [isSubmitting, setIsSubmitting] = useState(false)
const closeTime = dateToMillis(closeDate)
const closeTime = closeDate ? dateToMillis(closeDate) : undefined
const balance = creator?.balance || 0
@ -104,6 +104,7 @@ export function NewContract(props: { question: string }) {
initialProb,
ante,
closeTime,
tags: tag ? [tag] : [],
}).then((r) => r.data || {})
if (result.status !== 'success') {

View File

@ -28,9 +28,12 @@ import { useRouter } from 'next/router'
import clsx from 'clsx'
import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring'
import { Leaderboard } from '../../../components/leaderboard'
import { formatMoney } from '../../../lib/util/format'
import { formatMoney, toCamelCase } from '../../../lib/util/format'
import { EditFoldButton } from '../../../components/edit-fold-button'
import Custom404 from '../../404'
import { FollowFoldButton } from '../../../components/follow-fold-button'
import FeedCreate from '../../../components/feed-create'
import { SEO } from '../../../components/SEO'
export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
@ -96,7 +99,8 @@ async function toUserScores(userScores: { [userId: string]: number }) {
const topUserPairs = _.take(
_.sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
10
)
).filter(([_, score]) => score > 0)
const topUsers = await Promise.all(
topUserPairs.map(([userId]) => getUser(userId))
)
@ -138,7 +142,7 @@ export default function FoldPage(props: {
const page = (slugs[1] ?? 'activity') as typeof foldSubpages[number]
const fold = useFold(props.fold?.id ?? '') ?? props.fold
const fold = useFold(props.fold?.id) ?? props.fold
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort: 'most-traded',
@ -153,71 +157,96 @@ export default function FoldPage(props: {
return (
<Page wide>
<Col className="items-center">
<Col className="max-w-5xl w-full">
<Col className="sm:flex-row sm:justify-between sm:items-end gap-4 mb-6">
<Title className="!m-0" text={fold.name} />
{isCurator && <EditFoldButton fold={fold} />}
</Col>
<SEO
title={fold.name}
description={`Curated by ${curator.name}. ${fold.about}`}
url={foldPath(fold)}
/>
<div className="tabs mb-4">
<Link href={foldPath(fold)} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'activity' && 'tab-active'
)}
>
Activity
</a>
</Link>
<div className="px-3 lg:px-1">
<Row className="justify-between mb-6">
<Title className="!m-0" text={fold.name} />
{isCurator ? (
<EditFoldButton className="ml-1" fold={fold} />
) : (
<FollowFoldButton className="ml-1" fold={fold} />
)}
</Row>
<Link href={foldPath(fold, 'markets')} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'markets' && 'tab-active'
)}
>
Markets
</a>
</Link>
<Link href={foldPath(fold, 'leaderboards')} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'leaderboards' && 'tab-active',
page !== 'leaderboards' && 'md:hidden'
)}
>
Leaderboards
</a>
</Link>
</div>
<Col className="md:hidden text-gray-500 gap-2 mb-6">
<Row>
<div className="mr-1">Curated by</div>
<UserLink
className="text-neutral"
name={curator.name}
username={curator.username}
/>
</Row>
<div>{fold.about}</div>
</Col>
</div>
{page === 'activity' && (
<Row className="gap-8">
<Col>
<div className="tabs mb-2">
<Link href={foldPath(fold)} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'activity' && 'tab-active'
)}
>
Activity
</a>
</Link>
<Link href={foldPath(fold, 'markets')} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'markets' && 'tab-active'
)}
>
Markets
</a>
</Link>
<Link href={foldPath(fold, 'leaderboards')} shallow>
<a
className={clsx(
'tab tab-bordered',
page === 'leaderboards' && 'tab-active',
page !== 'leaderboards' && 'md:hidden'
)}
>
Leaderboards
</a>
</Link>
</div>
{(page === 'activity' || page === 'markets') && (
<Row className={clsx(page === 'activity' ? 'gap-16' : 'gap-8')}>
<Col className="flex-1">
{user !== null && (
<FeedCreate
className={clsx(page !== 'activity' && 'hidden')}
user={user}
tag={toCamelCase(fold.name)}
placeholder={`Type your question about ${fold.name}`}
/>
)}
{page === 'activity' ? (
<>
<ActivityFeed
contracts={activeContracts}
contractBets={activeContractBets}
contractComments={activeContractComments}
/>
</Col>
<Col className="hidden md:flex max-w-xs gap-10">
<FoldOverview fold={fold} curator={curator} />
<FoldLeaderboards
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
/>
</Col>
</Row>
)}
{page === 'markets' && (
<div className="w-full">
{activeContracts.length === 0 && (
<div className="text-gray-500 mt-4 mx-2 lg:mx-0">
No activity from matching markets.{' '}
{isCurator && 'Try editing to add more tags!'}
</div>
)}
</>
) : (
<SearchableGrid
contracts={contracts}
query={query}
@ -225,21 +254,30 @@ export default function FoldPage(props: {
sort={sort}
setSort={setSort}
/>
</div>
)}
)}
</Col>
<Col className="hidden md:flex max-w-xs w-full gap-10">
<FoldOverview fold={fold} curator={curator} />
<FoldLeaderboards
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
/>
</Col>
</Row>
)}
{page === 'leaderboards' && (
<Col className="gap-8 lg:flex-row">
<FoldLeaderboards
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
/>
</Col>
)}
{page === 'leaderboards' && (
<Col className="gap-8 lg:flex-row">
<FoldLeaderboards
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
/>
</Col>
</Col>
)}
</Page>
)
}
@ -249,11 +287,11 @@ function FoldOverview(props: { fold: Fold; curator: User }) {
const { about, tags } = fold
return (
<Col className="max-w-sm">
<div className="px-4 py-3 bg-indigo-700 text-white text-sm rounded-t">
<Col>
<div className="px-4 py-3 bg-indigo-500 text-white text-sm rounded-t">
About community
</div>
<Col className="p-4 bg-white self-start gap-2 rounded-b">
<Col className="p-4 bg-white gap-2 rounded-b">
<Row>
<div className="text-gray-500 mr-1">Curated by</div>
<UserLink

View File

@ -1,7 +1,9 @@
import _ from 'lodash'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Fold } from '../../common/fold'
import { CreateFoldButton } from '../components/create-fold-button'
import { FollowFoldButton } from '../components/follow-fold-button'
import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row'
import { Page } from '../components/page'
@ -39,7 +41,8 @@ export default function Folds(props: {
}) {
const [curatorsDict, setCuratorsDict] = useState(props.curatorsDict)
const folds = useFolds() ?? props.folds
let folds = useFolds() ?? props.folds
folds = _.sortBy(folds, (fold) => -1 * fold.followCount)
const user = useUser()
useEffect(() => {
@ -60,36 +63,26 @@ export default function Folds(props: {
return (
<Page>
<Col className="items-center">
<Col className="max-w-2xl w-full px-2 sm:px-0">
<Row className="justify-between items-center">
<Title text="Manifold communities: Folds" />
{user && <CreateFoldButton />}
</Row>
<Col className="max-w-lg w-full">
<Col className="px-4 sm:px-0">
<Row className="justify-between items-center">
<Title text="Explore folds" />
{user && <CreateFoldButton />}
</Row>
<div className="text-gray-500 mb-6">
Browse folds on topics that interest you.
</div>
<div className="text-gray-500 mb-6">
Folds are communities on Manifold centered around a collection of
markets.
</div>
</Col>
<Col className="gap-4">
<Col className="gap-2">
{folds.map((fold) => (
<Col key={fold.id} className="gap-2">
<Row className="items-center flex-wrap gap-2">
<SiteLink href={foldPath(fold)}>{fold.name}</SiteLink>
<div />
<div className="text-sm text-gray-500">12 followers</div>
<div className="text-gray-500"></div>
<Row>
<div className="text-sm text-gray-500 mr-1">Curated by</div>
<UserLink
className="text-sm text-neutral"
name={curatorsDict[fold.curatorId]?.name ?? ''}
username={curatorsDict[fold.curatorId]?.username ?? ''}
/>
</Row>
</Row>
<div className="text-gray-500 text-sm">{fold.about}</div>
<div />
</Col>
<FoldCard
key={fold.id}
fold={fold}
curator={curatorsDict[fold.curatorId]}
/>
))}
</Col>
</Col>
@ -97,3 +90,34 @@ export default function Folds(props: {
</Page>
)
}
function FoldCard(props: { fold: Fold; curator: User | undefined }) {
const { fold, curator } = props
return (
<Col
key={fold.id}
className="bg-white hover:bg-gray-100 p-4 rounded-xl gap-1 shadow-md relative"
>
<Link href={foldPath(fold)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
</Link>
<Row className="justify-between items-center gap-2">
<SiteLink href={foldPath(fold)}>{fold.name}</SiteLink>
<FollowFoldButton className="z-10 mb-1" fold={fold} />
</Row>
<Row className="items-center gap-2 text-gray-500 text-sm">
<div>{fold.followCount} followers</div>
<div></div>
<Row>
<div className="mr-1">Curated by</div>
<UserLink
className="text-neutral"
name={curator?.name ?? ''}
username={curator?.username ?? ''}
/>
</Row>
</Row>
<div className="text-gray-500 text-sm">{fold.about}</div>
</Col>
)
}

View File

@ -19,9 +19,6 @@ import FeedCreate from '../components/feed-create'
import { Spacer } from '../components/layout/spacer'
import { Col } from '../components/layout/col'
import { useUser } from '../hooks/use-user'
import { ClosingSoonMarkets, HotMarkets } from './markets'
import { useContracts } from '../hooks/use-contracts'
import { useRecentComments } from '../hooks/use-comments'
export async function getStaticProps() {
const [contracts, recentComments, hotContracts, closingSoonContracts] =
@ -66,8 +63,8 @@ const Home = (props: {
activeContracts,
activeContractBets,
activeContractComments,
hotContracts,
closingSoonContracts,
// hotContracts,
// closingSoonContracts,
} = props
const user = useUser()
@ -87,25 +84,23 @@ const Home = (props: {
}
return (
<Page>
<Page assertUser="signed-in">
<Col className="items-center">
<Col className="max-w-3xl">
<div className="-mx-2 sm:mx-0">
<FeedCreate user={user ?? undefined} />
<Spacer h={4} />
<FeedCreate user={user ?? undefined} />
<Spacer h={4} />
<HotMarkets contracts={hotContracts?.slice(0, 4) ?? []} />
{/* <HotMarkets contracts={hotContracts?.slice(0, 4) ?? []} />
<Spacer h={4} />
<ClosingSoonMarkets contracts={closingSoonContracts ?? []} />
<Spacer h={10} />
<Spacer h={10} /> */}
<ActivityFeed
contracts={activeContracts ?? []}
contractBets={activeContractBets ?? []}
contractComments={activeContractComments ?? []}
/>
</div>
<ActivityFeed
contracts={activeContracts ?? []}
contractBets={activeContractBets ?? []}
contractComments={activeContractComments ?? []}
/>
</Col>
</Col>
</Page>

View File

@ -27,12 +27,10 @@ const Home = (props: { hotContracts: Contract[] }) => {
}
return (
<Page>
<Page assertUser="signed-out">
<Col className="items-center">
<Col className="max-w-3xl">
<div className="-mx-2 sm:mx-0">
<FeedPromo hotContracts={hotContracts ?? []} />
</div>
<FeedPromo hotContracts={hotContracts ?? []} />
</Col>
</Col>
</Page>

View File

@ -28,7 +28,7 @@ export default function Leaderboards(props: {
const { topTraders, topCreators } = props
return (
<Page>
<Page margin>
<Col className="items-center lg:flex-row gap-10">
<Leaderboard
title="🏅 Top traders"

View File

@ -1,30 +1,16 @@
import _ from 'lodash'
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,
getClosingSoonContracts,
getHotContracts,
listAllContracts,
} from '../lib/firebase/contracts'
import { Contract, listAllContracts } from '../lib/firebase/contracts'
export async function getStaticProps() {
const [contracts, hotContracts, closingSoonContracts] = await Promise.all([
listAllContracts().catch((_) => []),
getHotContracts().catch(() => []),
getClosingSoonContracts().catch(() => []),
])
const contracts = await listAllContracts().catch((_) => [])
return {
props: {
contracts,
hotContracts,
closingSoonContracts,
},
revalidate: 60, // regenerate after a minute
@ -32,15 +18,11 @@ export async function getStaticProps() {
}
// 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
export default function Markets(props: { contracts: Contract[] }) {
const contracts = useContracts() ?? props.contracts ?? []
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort: 'most-traded',
defaultSort: '24-hour-vol',
})
return (

View File

@ -27,10 +27,8 @@ export default function TagPage(props: { contracts: Contract[] }) {
const contracts = useContracts()
const taggedContracts = (contracts ?? props.contracts).filter(
(contract) =>
contract.description.toLowerCase().includes(`#${tag.toLowerCase()}`) ||
contract.question.toLowerCase().includes(`#${tag.toLowerCase()}`)
const taggedContracts = (contracts ?? props.contracts).filter((contract) =>
contract.lowercaseTags.includes(tag.toLowerCase())
)
const { query, setQuery, sort, setSort } = useQueryAndSortParams({

View File

@ -10,7 +10,7 @@ export default function TradesPage() {
return (
<Page>
<SEO title="Your trades" description="Your trades" url="/trades" />
<Title text="Your trades" />
<Title className="mx-4 md:mx-0" text="Your trades" />
{user && <BetsList user={user} />}
</Page>
)