Merge branch 'main' into user-profile
This commit is contained in:
commit
7dcdfdaec6
|
@ -15,4 +15,6 @@ export type Fold = {
|
|||
// Default: creatorIds: undefined, excludedCreatorIds: []
|
||||
creatorIds?: string[]
|
||||
excludedCreatorIds?: string[]
|
||||
|
||||
followCount: number
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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'
|
||||
|
|
10
functions/src/on-fold-delete.ts
Normal file
10
functions/src/on-fold-delete.ts
Normal 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()))
|
||||
})
|
17
functions/src/on-fold-follow.ts
Normal file
17
functions/src/on-fold-follow.ts
Normal 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 })
|
||||
})
|
|
@ -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
34
web/components/avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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)}
|
||||
<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)}
|
||||
<span>(+{currentReturnPercent})</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
|
|
100
web/components/bet-row.tsx
Normal file
100
web/components/bet-row.tsx
Normal 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"
|
||||
>
|
||||
​
|
||||
</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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
47
web/components/follow-fold-button.tsx
Normal file
47
web/components/follow-fold-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
45
web/components/tags-input.tsx
Normal file
45
web/components/tags-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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] ?? ''
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user