Merge remote-tracking branch 'origin/main' into fix-large-groups

This commit is contained in:
Pico2x 2022-09-12 12:14:38 +01:00
commit 82772cb733
87 changed files with 1685 additions and 933 deletions

View File

@ -116,12 +116,12 @@ const calculateProfitForPeriod = (
return currentProfit return currentProfit
} }
const startingProfit = calculateTotalProfit(startingPortfolio) const startingProfit = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit return currentProfit - startingProfit
} }
const calculateTotalProfit = (portfolio: PortfolioMetrics) => { export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
} }
@ -129,7 +129,7 @@ export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[], portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics newPortfolio: PortfolioMetrics
) => { ) => {
const allTimeProfit = calculateTotalProfit(newPortfolio) const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy( const descendingPortfolio = sortBy(
portfolioHistory, portfolioHistory,
(p) => p.timestamp (p) => p.timestamp

View File

@ -1,6 +1,6 @@
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string userAvatarUrl?: string
} & T } & T
type OnContract = { export type OnContract = {
commentType: 'contract' commentType: 'contract'
contractId: string contractId: string
answerOutcome?: string answerOutcome?: string
@ -35,10 +35,16 @@ type OnContract = {
betOutcome?: string betOutcome?: string
} }
type OnGroup = { export type OnGroup = {
commentType: 'group' commentType: 'group'
groupId: string groupId: string
} }
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract> export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup> export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian 'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid 'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

@ -13,7 +13,6 @@ import { addObjects } from './util/object'
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => { export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
const { pool } = contract const { pool } = contract
const poolTotal = sum(Object.values(pool)) const poolTotal = sum(Object.values(pool))
console.log('resolved N/A, pool M$', poolTotal)
const betSum = sumBy(bets, (b) => b.amount) const betSum = sumBy(bets, (b) => b.amount)
@ -58,17 +57,6 @@ export const getDpmStandardPayouts = (
liquidityFee: 0, liquidityFee: 0,
}) })
console.log(
'resolved',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return { return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })), payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee, creatorPayout: creatorFee,
@ -110,17 +98,6 @@ export const getNumericDpmPayouts = (
liquidityFee: 0, liquidityFee: 0,
}) })
console.log(
'resolved numeric bucket: ',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return { return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })), payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee, creatorPayout: creatorFee,
@ -163,17 +140,6 @@ export const getDpmMktPayouts = (
liquidityFee: 0, liquidityFee: 0,
}) })
console.log(
'resolved MKT',
p,
'pool',
pool,
'profits',
profits,
'creator fee',
creatorFee
)
return { return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })), payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee, creatorPayout: creatorFee,
@ -216,16 +182,6 @@ export const getPayoutsMultiOutcome = (
liquidityFee: 0, liquidityFee: 0,
}) })
console.log(
'resolved',
resolutions,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return { return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })), payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee, creatorPayout: creatorFee,

View File

@ -1,4 +1,3 @@
import { sum } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getProbability } from './calculate' import { getProbability } from './calculate'
@ -43,18 +42,6 @@ export const getStandardFixedPayouts = (
const { collectedFees } = contract const { collectedFees } = contract
const creatorPayout = collectedFees.creatorFee const creatorPayout = collectedFees.creatorFee
console.log(
'resolved',
outcome,
'pool',
contract.pool[outcome],
'payouts',
sum(payouts),
'creator fee',
creatorPayout
)
const liquidityPayouts = getLiquidityPoolPayouts( const liquidityPayouts = getLiquidityPoolPayouts(
contract, contract,
outcome, outcome,
@ -98,18 +85,6 @@ export const getMktFixedPayouts = (
const { collectedFees } = contract const { collectedFees } = contract
const creatorPayout = collectedFees.creatorFee const creatorPayout = collectedFees.creatorFee
console.log(
'resolved PROB',
p,
'pool',
p * contract.pool.YES + (1 - p) * contract.pool.NO,
'payouts',
sum(payouts),
'creator fee',
creatorPayout
)
const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities) const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities)
return { payouts, creatorPayout, liquidityPayouts, collectedFees } return { payouts, creatorPayout, liquidityPayouts, collectedFees }

View File

@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares) const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0) const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0 const soldFrac =
shares > 0
? Math.min(yesShares, noShares) / Math.max(yesShares, noShares)
: 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = loanAmount * soldFrac const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment const netAmount = shares - loanPayment

View File

@ -1,8 +1,8 @@
import { groupBy, sumBy, mapValues, partition } from 'lodash' import { groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[]) { export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues( const creatorScore = mapValues(
@ -30,46 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
} }
export function scoreUsersByContract(contract: Contract, bets: Bet[]) { export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const { resolution } = contract const betsByUser = groupBy(bets, bet => bet.userId)
const resolutionProb = return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
contract.outcomeType == 'BINARY'
? contract.resolutionProbability
: undefined
const [closedBets, openBets] = partition(
bets,
(bet) => bet.isSold || bet.sale
)
const { payouts: resolvePayouts } = getPayouts(
resolution as string,
contract,
openBets,
[],
{},
resolutionProb
)
const salePayouts = closedBets.map((bet) => {
const { userId, sale } = bet
return { userId, payout: sale ? sale.amount : 0 }
})
const investments = bets
.filter((bet) => !bet.sale)
.map((bet) => {
const { userId, amount, loanAmount } = bet
const payout = -amount - (loanAmount ?? 0)
return { userId, payout }
})
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
const userScore = mapValues(
groupBy(netPayouts, (payout) => payout.userId),
(payouts) => sumBy(payouts, ({ payout }) => payout)
)
return userScore
} }
export function addUserScores( export function addUserScores(

View File

@ -34,7 +34,7 @@ export type User = {
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
homeSections?: { visible: string[]; hidden: string[] } homeSections?: string[]
referredByUserId?: string referredByUserId?: string
referredByContractId?: string referredByContractId?: string

View File

@ -60,23 +60,27 @@ Parameters:
Requires no authorization. Requires no authorization.
### `GET /v0/groups/[slug]` ### `GET /v0/group/[slug]`
Gets a group by its slug. Gets a group by its slug.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]` ### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID. Gets a group by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets` ### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID. Gets a group's markets by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets` ### `GET /v0/markets`

View File

@ -12,7 +12,9 @@ service cloud.firestore {
'taowell@gmail.com', 'taowell@gmail.com',
'abc.sinclair@gmail.com', 'abc.sinclair@gmail.com',
'manticmarkets@gmail.com', 'manticmarkets@gmail.com',
'iansphilips@gmail.com' 'iansphilips@gmail.com',
'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com'
] ]
} }
@ -203,6 +205,10 @@ service cloud.firestore {
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'content']); .hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
}
} }
} }
} }

View File

@ -37,6 +37,45 @@ export const changeUser = async (
avatarUrl?: string avatarUrl?: string
} }
) => { ) => {
// Update contracts, comments, and answers outside of a transaction to avoid contention.
// Using bulkWriter to supports >500 writes at a time
const contractsRef = firestore
.collection('contracts')
.where('creatorId', '==', user.id)
const contracts = await contractsRef.get()
const contractUpdate: Partial<Contract> = removeUndefinedProps({
creatorName: update.name,
creatorUsername: update.username,
creatorAvatarUrl: update.avatarUrl,
})
const commentSnap = await firestore
.collectionGroup('comments')
.where('userUsername', '==', user.username)
.get()
const commentUpdate: Partial<Comment> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
const answerSnap = await firestore
.collectionGroup('answers')
.where('username', '==', user.username)
.get()
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
const bulkWriter = firestore.bulkWriter()
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
await bulkWriter.flush()
console.log('Done writing!')
// Update the username inside a transaction
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
if (update.username) { if (update.username) {
update.username = cleanUsername(update.username) update.username = cleanUsername(update.username)
@ -58,42 +97,7 @@ export const changeUser = async (
const userRef = firestore.collection('users').doc(user.id) const userRef = firestore.collection('users').doc(user.id)
const userUpdate: Partial<User> = removeUndefinedProps(update) const userUpdate: Partial<User> = removeUndefinedProps(update)
const contractsRef = firestore
.collection('contracts')
.where('creatorId', '==', user.id)
const contracts = await transaction.get(contractsRef)
const contractUpdate: Partial<Contract> = removeUndefinedProps({
creatorName: update.name,
creatorUsername: update.username,
creatorAvatarUrl: update.avatarUrl,
})
const commentSnap = await transaction.get(
firestore
.collectionGroup('comments')
.where('userUsername', '==', user.username)
)
const commentUpdate: Partial<Comment> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
const answerSnap = await transaction.get(
firestore
.collectionGroup('answers')
.where('username', '==', user.username)
)
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
transaction.update(userRef, userUpdate) transaction.update(userRef, userUpdate)
commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
}) })
} }

View File

@ -186,8 +186,9 @@
font-family: Readex Pro, Arial, Helvetica, font-family: Readex Pro, Arial, Helvetica,
sans-serif; sans-serif;
font-size: 17px; font-size: 17px;
">Did you know you create your own prediction market on <a class="link-build-content" ">Did you know you can create your own prediction market on <a
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for class="link-build-content" style="color: #55575d" target="_blank"
href="https://manifold.markets">Manifold</a> on
any question you care about?</span> any question you care about?</span>
</p> </p>

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) || !isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
) { ) {
throw new APIError(400, 'Bet too large for current liquidity pool.') throw new APIError(400, 'Trade too large for current liquidity pool.')
} }
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()

View File

@ -122,6 +122,18 @@ export function BuyAmountInput(props: {
} }
} }
const parseRaw = (x: number) => {
if (x <= 100) return x
if (x <= 130) return 100 + (x - 100) * 5
return 250 + (x - 130) * 10
}
const getRaw = (x: number) => {
if (x <= 100) return x
if (x <= 250) return 100 + (x - 100) / 5
return 130 + (x - 250) / 10
}
return ( return (
<> <>
<AmountInput <AmountInput
@ -138,10 +150,10 @@ export function BuyAmountInput(props: {
<input <input
type="range" type="range"
min="0" min="0"
max="200" max="205"
value={amount ?? 0} value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseInt(e.target.value))} onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg z-40 mb-2 xl:hidden" className="range range-lg only-thumb z-40 mb-2 xl:hidden"
step="5" step="5"
/> />
)} )}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react' import React, { useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
@ -25,8 +25,7 @@ import {
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { BetSignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device' import { WarningConfirmationButton } from '../warning-confirmation-button'
import { AlertBox } from '../alert-box'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
@ -44,12 +43,6 @@ export function AnswerBetPanel(props: {
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const inputRef = useRef<HTMLElement>(null)
useEffect(() => {
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
inputRef.current && inputRef.current.focus()
}, [])
async function submitBet() { async function submitBet() {
if (!user || !betAmount) return if (!user || !betAmount) return
@ -116,11 +109,20 @@ export function AnswerBetPanel(props: {
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
? `You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`
: undefined
return ( return (
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch"> <Row className="items-center justify-between self-stretch">
<div className="text-xl"> <div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'} Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div> </div>
{!isModal && ( {!isModal && (
@ -144,25 +146,9 @@ export function AnswerBetPanel(props: {
error={error} error={error}
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile showSliderOnMobile
/> />
{(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (
''
)}
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div> <div className="text-gray-500">Probability</div>
@ -198,16 +184,17 @@ export function AnswerBetPanel(props: {
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<button <WarningConfirmationButton
className={clsx( warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn self-stretch', 'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary', betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : '' isSubmitting ? 'loading' : ''
)} )}
onClick={betDisabled ? undefined : submitBet} />
>
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />
)} )}

View File

@ -194,7 +194,7 @@ function OpenAnswer(props: {
return ( return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}> <Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
contract={contract} contract={contract}

View File

@ -12,47 +12,33 @@ import { User } from 'common/user'
import { Group } from 'common/group' import { Group } from 'common/group'
export function ArrangeHome(props: { export function ArrangeHome(props: {
user: User | null user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] } homeSections: string[]
setHomeSections: (homeSections: { setHomeSections: (sections: string[]) => void
visible: string[]
hidden: string[]
}) => void
}) { }) {
const { user, homeSections, setHomeSections } = props const { user, homeSections, setHomeSections } = props
const groups = useMemberGroups(user?.id) ?? [] const groups = useMemberGroups(user?.id) ?? []
const { itemsById, visibleItems, hiddenItems } = getHomeItems( const { itemsById, sections } = getHomeItems(groups, homeSections)
groups,
homeSections
)
return ( return (
<DragDropContext <DragDropContext
onDragEnd={(e) => { onDragEnd={(e) => {
console.log('drag end', e)
const { destination, source, draggableId } = e const { destination, source, draggableId } = e
if (!destination) return if (!destination) return
const item = itemsById[draggableId] const item = itemsById[draggableId]
const newHomeSections = { const newHomeSections = sections.map((section) => section.id)
visible: visibleItems.map((item) => item.id),
hidden: hiddenItems.map((item) => item.id),
}
const sourceSection = source.droppableId as 'visible' | 'hidden' newHomeSections.splice(source.index, 1)
newHomeSections[sourceSection].splice(source.index, 1) newHomeSections.splice(destination.index, 0, item.id)
const destSection = destination.droppableId as 'visible' | 'hidden'
newHomeSections[destSection].splice(destination.index, 0, item.id)
setHomeSections(newHomeSections) setHomeSections(newHomeSections)
}} }}
> >
<Row className="relative max-w-lg gap-4"> <Row className="relative max-w-md gap-4">
<DraggableList items={visibleItems} title="Visible" /> <DraggableList items={sections} title="Sections" />
<DraggableList items={hiddenItems} title="Hidden" />
</Row> </Row>
</DragDropContext> </DragDropContext>
) )
@ -65,16 +51,13 @@ function DraggableList(props: {
const { title, items } = props const { title, items } = props
return ( return (
<Droppable droppableId={title.toLowerCase()}> <Droppable droppableId={title.toLowerCase()}>
{(provided, snapshot) => ( {(provided) => (
<Col <Col
{...provided.droppableProps} {...provided.droppableProps}
ref={provided.innerRef} ref={provided.innerRef}
className={clsx( className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')}
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
snapshot.isDraggingOver && 'bg-gray-100'
)}
> >
<Subtitle text={title} className="mx-2 !my-2" /> <Subtitle text={title} className="mx-2 !mt-0 !mb-4" />
{items.map((item, index) => ( {items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}> <Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => ( {(provided, snapshot) => (
@ -83,16 +66,13 @@ function DraggableList(props: {
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
style={provided.draggableProps.style} style={provided.draggableProps.style}
className={clsx(
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
snapshot.isDragging && 'z-[9000] bg-gray-300'
)}
> >
<MenuIcon <SectionItem
className="h-5 w-5 flex-shrink-0 text-gray-500" className={clsx(
aria-hidden="true" snapshot.isDragging && 'z-[9000] bg-gray-200'
/>{' '} )}
{item.label} item={item}
/>
</div> </div>
)} )}
</Draggable> </Draggable>
@ -104,15 +84,33 @@ function DraggableList(props: {
) )
} }
export const getHomeItems = ( const SectionItem = (props: {
groups: Group[], item: { id: string; label: string }
homeSections: { visible: string[]; hidden: string[] } className?: string
) => { }) => {
const { item, className } = props
return (
<div
className={clsx(
className,
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
)}
>
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</div>
)
}
export const getHomeItems = (groups: Group[], sections: string[]) => {
const items = [ const items = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' }, { label: 'New for you', id: 'newest' },
{ label: 'Close date', id: 'close-date' },
{ label: 'Your bets', id: 'your-bets' },
...groups.map((g) => ({ ...groups.map((g) => ({
label: g.name, label: g.name,
id: g.id, id: g.id,
@ -120,23 +118,13 @@ export const getHomeItems = (
] ]
const itemsById = keyBy(items, 'id') const itemsById = keyBy(items, 'id')
const { visible, hidden } = homeSections const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
const [visibleItems, hiddenItems] = [ // Add unmentioned items to the end.
filterDefined(visible.map((id) => itemsById[id])), sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
filterDefined(hidden.map((id) => itemsById[id])),
]
// Add unmentioned items to the visible list.
visibleItems.push(
...items.filter(
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
)
)
return { return {
visibleItems, sections: sectionItems,
hiddenItems,
itemsById, itemsById,
} }
} }

View File

@ -2,6 +2,7 @@ import Router from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { MouseEvent, useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
import Image from 'next/future/image'
export function Avatar(props: { export function Avatar(props: {
username?: string username?: string
@ -14,6 +15,7 @@ export function Avatar(props: {
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4
const onClick = const onClick =
noLink && username noLink && username
@ -26,7 +28,9 @@ export function Avatar(props: {
// there can be no avatar URL or username in the feed, we show a "submit comment" // there can be no avatar URL or username in the feed, we show a "submit comment"
// item with a fake grey user circle guy even if you aren't signed in // item with a fake grey user circle guy even if you aren't signed in
return avatarUrl ? ( return avatarUrl ? (
<img <Image
width={sizeInPx}
height={sizeInPx}
className={clsx( className={clsx(
'flex-shrink-0 rounded-full bg-white object-cover', 'flex-shrink-0 rounded-full bg-white object-cover',
`w-${s} h-${s}`, `w-${s} h-${s}`,

View File

@ -35,10 +35,13 @@ export default function BetButton(props: {
{user ? ( {user ? (
<Button <Button
size="lg" size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} className={clsx(
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
btnClassName
)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
Bet Predict
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />
@ -57,7 +60,7 @@ export default function BetButton(props: {
)} )}
</Col> </Col>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen} position="center">
<SimpleBetPanel <SimpleBetPanel
className={betPanelClassName} className={betPanelClassName}
contract={contract} contract={contract}

View File

@ -40,7 +40,8 @@ import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { AlertBox } from './alert-box' import { isAndroid, isIOS } from 'web/lib/util/device'
import { WarningConfirmationButton } from './warning-confirmation-button'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -184,18 +185,14 @@ function BuyPanel(props: {
const [inputRef, focusAmountInput] = useFocus() const [inputRef, focusAmountInput] = useFocus()
// useEffect(() => {
// if (selected) {
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
// focusAmountInput()
// }
// }, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice) setOutcome(choice)
setWasSubmitted(false) setWasSubmitted(false)
if (!isIOS() && !isAndroid()) {
focusAmountInput() focusAmountInput()
} }
}
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false) setWasSubmitted(false)
@ -274,25 +271,15 @@ function BuyPanel(props: {
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning = const warning =
(betAmount ?? 0) > 10 && (betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
bankrollFraction >= 0.5 && ? `You might not want to spend ${formatPercent(
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( )} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0 user?.balance ?? 0
)}`} )}`
/> : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? ( ? `Are you sure you want to move the market by ${displayedDifference}?`
<AlertBox : undefined
title="Whoa, there!"
text={`Are you sure you want to move the market by ${displayedDifference}?`}
/>
) : (
<></>
)
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
@ -325,8 +312,6 @@ function BuyPanel(props: {
showSliderOnMobile showSliderOnMobile
/> />
{warning}
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500"> <div className="text-gray-500">
@ -367,23 +352,23 @@ function BuyPanel(props: {
<Spacer h={8} /> <Spacer h={8} />
{user && ( {user && (
<button <WarningConfirmationButton
className={clsx( warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn mb-2 flex-1', 'btn mb-2 flex-1',
betDisabled betDisabled
? 'btn-disabled' ? 'btn-disabled'
: outcome === 'YES' : outcome === 'YES'
? 'btn-primary' ? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500', : 'border-none bg-red-400 hover:bg-red-500'
isSubmitting ? 'loading' : ''
)} )}
onClick={betDisabled ? undefined : submitBet} />
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col> </Col>
) )
} }
@ -569,7 +554,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -580,7 +565,7 @@ function LimitOrderPanel(props: {
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -750,15 +735,16 @@ function QuickOrLimitBet(props: {
return ( return (
<Row className="align-center mb-4 justify-between"> <Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div> <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
{!hideToggle && ( {!hideToggle && (
<Row className="mt-1 items-center gap-2"> <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton <PillButton
selected={!isLimitOrder} selected={!isLimitOrder}
onSelect={() => { onSelect={() => {
setIsLimitOrder(false) setIsLimitOrder(false)
track('select quick order') track('select quick order')
}} }}
xs={true}
> >
Quick Quick
</PillButton> </PillButton>
@ -768,6 +754,7 @@ function QuickOrLimitBet(props: {
setIsLimitOrder(true) setIsLimitOrder(true)
track('select limit order') track('select limit order')
}} }}
xs={true}
> >
Limit Limit
</PillButton> </PillButton>

View File

@ -5,19 +5,19 @@ export function PillButton(props: {
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
color?: string color?: string
big?: boolean xs?: boolean
children: ReactNode children: ReactNode
}) { }) {
const { children, selected, onSelect, color, big } = props const { children, selected, onSelect, color, xs } = props
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm',
xs ? 'text-xs' : '',
selected selected
? ['text-white', color ?? 'bg-greyscale-6'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3', : 'bg-greyscale-2 hover:bg-greyscale-3'
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)} )}
onClick={onSelect} onClick={onSelect}
> >

View File

@ -38,7 +38,7 @@ export function Carousel(props: {
return ( return (
<div className={clsx('relative', className)}> <div className={clsx('relative', className)}>
<Row <Row
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
ref={ref} ref={ref}
onScroll={onScroll} onScroll={onScroll}
> >

View File

@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
setErrorText('') setErrorText('')
}, [open]) }, [open])
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" /> if (!user)
return <BetSignUpPrompt label="Sign up to accept" className="mt-4" />
const iAcceptChallenge = () => { const iAcceptChallenge = () => {
setLoading(true) setLoading(true)

View File

@ -0,0 +1,169 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
replyToUser,
} = props
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// Submit on ctrl+enter or mod+enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
(event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
{children} {children}
<Row className="gap-4"> <Row className="gap-4">
<div <div
className={clsx('btn normal-case', cancelBtn?.className)} className={clsx('btn', cancelBtn?.className)}
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
{cancelBtn?.label ?? 'Cancel'} {cancelBtn?.label ?? 'Cancel'}
</div> </div>
<div <div
className={clsx('btn normal-case', submitBtn?.className)} className={clsx('btn', submitBtn?.className)}
onClick={ onClick={
onSubmitWithSuccess onSubmitWithSuccess
? () => ? () =>
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
</Col> </Col>
</Modal> </Modal>
<div <div
className={clsx('btn normal-case', openModalBtn.className)} className={clsx('btn', openModalBtn.className)}
onClick={() => updateOpen(true)} onClick={() => updateOpen(true)}
> >
{openModalBtn.icon} {openModalBtn.icon}

View File

@ -69,6 +69,7 @@ type AdditionalFilter = {
excludeContractIds?: string[] excludeContractIds?: string[]
groupSlug?: string groupSlug?: string
yourBets?: boolean yourBets?: boolean
followed?: boolean
} }
export function ContractSearch(props: { export function ContractSearch(props: {
@ -88,6 +89,7 @@ export function ContractSearch(props: {
useQueryUrlParam?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
noControls?: boolean noControls?: boolean
maxResults?: number
renderContracts?: ( renderContracts?: (
contracts: Contract[] | undefined, contracts: Contract[] | undefined,
loadMore: () => void loadMore: () => void
@ -107,6 +109,7 @@ export function ContractSearch(props: {
useQueryUrlParam, useQueryUrlParam,
isWholePage, isWholePage,
noControls, noControls,
maxResults,
renderContracts, renderContracts,
} = props } = props
@ -189,7 +192,8 @@ export function ContractSearch(props: {
const contracts = state.pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = state.pages.length === 0 ? undefined : contracts const renderedContracts =
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -292,6 +296,19 @@ function ContractSearchControls(props: {
const pillGroups: { name: string; slug: string }[] = const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [ const additionalFilters = [
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
@ -304,6 +321,7 @@ function ContractSearchControls(props: {
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
...(additionalFilter?.followed ? personalFilters : []),
] ]
const facetFilters = query const facetFilters = query
? additionalFilters ? additionalFilters
@ -320,17 +338,7 @@ function ContractSearchControls(props: {
state.pillFilter !== 'your-bets' state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}` ? `groupLinks.slug:${state.pillFilter}`
: '', : '',
state.pillFilter === 'personal' ...(state.pillFilter === 'personal' ? personalFilters : []),
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
: '',
// Subtract contracts you bet on from For you.
state.pillFilter === 'personal' && user
? `uniqueBettorIds:-${user.id}`
: '',
state.pillFilter === 'your-bets' && user state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
@ -441,7 +449,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your trades
</PillButton> </PillButton>
)} )}

View File

@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip <Tooltip
text={`${formatMoney( text={`${formatMoney(
volume volume
)} bet - ${uniqueBettors} unique bettors`} )} bet - ${uniqueBettors} unique traders`}
> >
{volumeTranslation} {volumeTranslation}
</Tooltip> </Tooltip>

View File

@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
</tr> */} </tr> */}
<tr> <tr>
<td>Bettors</td> <td>Traders</td>
<td>{bettorsCount}</td> <td>{bettorsCount}</td>
</tr> </tr>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top traders"
users={users || []} users={users || []}
columns={[ columns={[
{ {

View File

@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
@ -27,24 +26,23 @@ export function ContractTabs(props: {
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, tips } = props const { contract, user, bets, tips } = props
const { outcomeType } = contract const { outcomeType } = contract
const bets = useBets(contract.id) ?? props.bets const lps = useLiquidity(contract.id)
const lps = useLiquidity(contract.id) ?? []
const userBets = const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0) const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
// Load comments here, so the badge count will be correct // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments
const betActivity = ( const betActivity = visibleLps && (
<ContractBetsActivity <ContractBetsActivity
contract={contract} contract={contract}
bets={visibleBets} bets={visibleBets}
@ -116,13 +114,13 @@ export function ContractTabs(props: {
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ {
title: 'Bets', title: 'Trades',
content: betActivity, content: betActivity,
badge: `${visibleBets.length}`, badge: `${visibleBets.length}`,
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your trades', content: yourTrades }]),
]} ]}
/> />
{!user ? ( {!user ? (

View File

@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{ additionalFilter={{
creatorId: creator.id, creatorId: creator.id,
}} }}
persistPrefix={`user-${creator.id}`}
/> />
) )
} }

View File

@ -14,6 +14,7 @@ import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) { export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props const { contract } = props
@ -61,9 +62,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
)} )}
> >
<Col className="items-center sm:flex-row"> <Col className="items-center sm:flex-row">
<span className="h-[24px] w-5 sm:mr-2" aria-hidden="true"> <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
</span>
<span>Challenge</span> <span>Challenge</span>
</Col> </Col>
<CreateChallengeModal <CreateChallengeModal

View File

@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
return ( return (
<Button <Button
size={'lg'} size={'lg'}
className={'mb-1'} className={'max-w-xs self-center'}
color={'gray-white'} color={'gray-white'}
onClick={onLike} onClick={onLike}
> >
<Col className={'items-center sm:flex-row sm:gap-x-2'}> <Col className={'items-center sm:flex-row'}>
<HeartIcon <HeartIcon
className={clsx( className={clsx(
'h-6 w-6', 'h-[24px] w-5 sm:mr-2',
user && user &&
(userLikedContractIds?.includes(contract.id) || (userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id))) (!likes && contract.likedByUserIds?.includes(user.id)))

View File

@ -2,53 +2,70 @@ import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract' import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format' import { formatPercent } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
export function ProbChangeTable(props: { userId: string | undefined }) { export function ProbChangeTable(props: {
const { userId } = props changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
}) {
const { changes } = props
const changes = useProbChanges(userId ?? '') if (!changes) return <LoadingIndicator />
if (!changes) {
return null
}
const { positiveChanges, negativeChanges } = changes const { positiveChanges, negativeChanges } = changes
const count = 3 const threshold = 0.075
const countOverThreshold = Math.max(
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
)
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
const rows = Math.min(3, Math.min(maxRows, countOverThreshold))
const filteredPositiveChanges = positiveChanges.slice(0, rows)
const filteredNegativeChanges = negativeChanges.slice(0, rows)
if (rows === 0) return <div className="px-4 text-gray-500">None</div>
return ( return (
<Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md"> <Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="min-w-[300px] flex-1 divide-y"> <Col className="flex-1 divide-y">
{positiveChanges.slice(0, count).map((contract) => ( {filteredPositiveChanges.map((contract) => (
<Row className="hover:bg-gray-100"> <Row className="items-center hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} /> <ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink <SiteLink
className="p-4 font-semibold text-indigo-700" className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)} href={contractPath(contract)}
> >
{contract.question} <span className="line-clamp-2">{contract.question}</span>
</SiteLink> </SiteLink>
</Row> </Row>
))} ))}
</Col> </Col>
<Col className="justify-content-stretch min-w-[300px] flex-1 divide-y"> <Col className="flex-1 divide-y">
{negativeChanges.slice(0, count).map((contract) => ( {filteredNegativeChanges.map((contract) => (
<Row className="hover:bg-gray-100"> <Row className="items-center hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} /> <ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink <SiteLink
className="p-4 font-semibold text-indigo-700" className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)} href={contractPath(contract)}
> >
{contract.question} <span className="line-clamp-2">{contract.question}</span>
</SiteLink> </SiteLink>
</Row> </Row>
))} ))}
</Col> </Col>
</Row> </Col>
) )
} }
@ -63,9 +80,9 @@ export function ProbChange(props: {
const color = const color =
change > 0 change > 0
? 'text-green-600' ? 'text-green-500'
: change < 0 : change < 0
? 'text-red-600' ? 'text-red-500'
: 'text-gray-600' : 'text-gray-600'
const str = const str =

View File

@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
export function DoubleCarousel(props: { export function DoubleCarousel(props: {
contracts: Contract[] contracts: Contract[]
seeMoreUrl?: string
showTime?: ShowTime showTime?: ShowTime
loadMore?: () => void loadMore?: () => void
}) { }) {
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
? range(0, Math.floor(contracts.length / 2)).map((col) => { ? range(0, Math.floor(contracts.length / 2)).map((col) => {
const i = col * 2 const i = col * 2
return ( return (
<Col key={contracts[i].id}> <Col className="snap-start scroll-m-4" key={contracts[i].id}>
<ContractCard <ContractCard
contract={contracts[i]} contract={contracts[i]}
className="mb-2 w-96 shrink-0" className="mb-2 w-96 shrink-0"

View File

@ -18,7 +18,6 @@ import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query' import { useMutation } from 'react-query'
import { FileUploadButton } from './file-upload-button' import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion' import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
@ -68,8 +67,6 @@ export function useTextEditor(props: {
}) { }) {
const { placeholder, max, defaultValue = '', disabled, simple } = props const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers()
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
!simple && 'min-h-[6em]', !simple && 'min-h-[6em]',
@ -78,8 +75,7 @@ export function useTextEditor(props: {
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
) )
const editor = useEditor( const editor = useEditor({
{
editorProps: { attributes: { class: editorClass } }, editorProps: { attributes: { class: editorClass } },
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
@ -94,16 +90,12 @@ export function useTextEditor(props: {
CharacterCount.configure({ limit: max }), CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image, simple ? DisplayImage : Image,
DisplayLink, DisplayLink,
DisplayMention.configure({ DisplayMention.configure({ suggestion: mentionSuggestion }),
suggestion: mentionSuggestion(users),
}),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
], ],
content: defaultValue, content: defaultValue,
}, })
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
)
const upload = useUploadMutation(editor) const upload = useUploadMutation(editor)

View File

@ -1,9 +1,9 @@
import type { MentionOptions } from '@tiptap/extension-mention' import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import tippy from 'tippy.js' import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list' import { MentionList } from './mention-list'
type Suggestion = MentionOptions['suggestion'] type Suggestion = MentionOptions['suggestion']
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage // copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion = (users: User[]): Suggestion => ({ export const mentionSuggestion: Suggestion = {
items: ({ query }) => items: async ({ query }) =>
orderBy( orderBy(
users.filter((u) => searchInAny(query, u.username, u.name)), (await getCachedUsers()).filter((u) =>
searchInAny(query, u.username, u.name)
),
[ [
(u) => [u.name, u.username].some((s) => beginsWith(s, query)), (u) => [u.name, u.username].some((s) => beginsWith(s, query)),
'followerCountCached', 'followerCountCached',
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
popup = tippy('body', { popup = tippy('body', {
getReferenceClientRect: props.clientRect as any, getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body, appendTo: () => document.body,
content: component.element, content: component?.element,
showOnCreate: true, showOnCreate: true,
interactive: true, interactive: true,
trigger: 'manual', trigger: 'manual',
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
}) })
}, },
onUpdate(props) { onUpdate(props) {
component.updateProps(props) component?.updateProps(props)
if (!props.clientRect) { if (!props.clientRect) {
return return
} }
popup[0].setProps({ popup?.[0].setProps({
getReferenceClientRect: props.clientRect as any, getReferenceClientRect: props.clientRect as any,
}) })
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() popup?.[0].hide()
return true return true
} }
return (component.ref as any)?.onKeyDown(props) return (component?.ref as any)?.onKeyDown(props)
}, },
onExit() { onExit() {
popup[0].destroy() popup?.[0].destroy()
component.destroy() component?.destroy()
}, },
} }
}, },
}) }

View File

@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets' import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity' import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group' import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments' import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user' import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
return ( return (
<> <>
<CommentInput <ContractCommentInput
className="mb-5" className="mb-5"
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}

View File

@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
CommentInput, ContractCommentInput,
FeedComment, FeedComment,
getMostRecentCommentableBet, getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser}

View File

@ -13,22 +13,17 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { import { createCommentOnContract } from 'web/lib/firebase/comments'
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets' import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper' import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size' import { Content } from '../editor'
import { Content, TextEditor, useTextEditor } from '../editor'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
user: User | null | undefined user: User | null | undefined
@ -90,14 +85,16 @@ export function FeedCommentThread(props: {
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome} parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => {
setShowReply(false)
}}
/> />
</Col> </Col>
)} )}
@ -267,67 +264,76 @@ function CommentStatus(props: {
) )
} }
//TODO: move commentinput and comment input text area into their own files export function ContractCommentInput(props: {
export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[] commentsByCurrentUser: ContractComment[]
className?: string className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string parentCommentId?: string
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser() const user = useUser()
const { editor, upload } = useTextEditor({ async function onSubmitComment(editor: Editor, betId: string | undefined) {
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(betId: string | undefined) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract( await createCommentOnContract(
contract.id, props.contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
betId, betId,
parentAnswerOutcome, props.parentAnswerOutcome,
parentCommentId props.parentCommentId
) )
onSubmitComment?.() props.onSubmitComment?.()
setIsSubmitting(false)
} }
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
className={props.className}
mostRecentCommentableBet={mostRecentCommentableBet}
/>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
</Col>
)
}
function CommentBetArea(props: {
betsByCurrentUser: Bet[]
contract: Contract
commentsByCurrentUser: ContractComment[]
parentAnswerOutcome?: string
user?: User | null
className?: string
mostRecentCommentableBet?: Bet
}) {
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
Date.now(), Date.now(),
@ -336,26 +342,15 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC' const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<div className="mb-1 text-gray-500"> <div className="mb-1 text-gray-500">
{mostRecentCommentableBet && ( {mostRecentCommentableBet && (
<BetStatusText <BetStatusText
contract={contract} contract={contract}
bet={mostRecentCommentableBet} bet={mostRecentCommentableBet}
isSelf={true} isSelf={true}
hideOutcome={ hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
}
/> />
)} )}
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
@ -373,121 +368,10 @@ export function CommentInput(props: {
</> </>
)} )}
</div> </div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
/>
</div>
</Row> </Row>
) )
} }
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}
function getBettorsLargestPositionBeforeTime( function getBettorsLargestPositionBeforeTime(
contract: Contract, contract: Contract,
createdTime: number, createdTime: number,

View File

@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<div className="m-4 max-w-[550px] self-center"> <div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl"> <h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2"> <div className="font-semibold sm:mb-2">
Predict{' '} A{' '}
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
anything! market
</span> </span>{' '}
for every question
</div> </div>
</h1> </h1>
<Spacer h={6} /> <Spacer h={6} />
<div className="mb-4 px-2 "> <div className="mb-4 px-2 ">
Create a play-money prediction market on any topic you care about Create a play-money prediction market on any topic you care about.
and bet with your friends on what will happen! Trade with your friends to forecast the future.
<br /> <br />
{/* <br />
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
<SiteLink className="font-semibold" href="/charity">
favorite charity.
</SiteLink>
<br /> */}
</div> </div>
</div> </div>
<Spacer h={6} /> <Spacer h={6} />

View File

@ -8,9 +8,10 @@ export function Modal(props: {
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
size?: 'sm' | 'md' | 'lg' | 'xl' size?: 'sm' | 'md' | 'lg' | 'xl'
position?: 'center' | 'top' | 'bottom'
className?: string className?: string
}) { }) {
const { children, open, setOpen, size = 'md', className } = props const { children, position, open, setOpen, size = 'md', className } = props
const sizeClass = { const sizeClass = {
sm: 'max-w-sm', sm: 'max-w-sm',
@ -19,6 +20,12 @@ export function Modal(props: {
xl: 'max-w-5xl', xl: 'max-w-5xl',
}[size] }[size]
const positionClass = {
center: 'items-center',
top: 'items-start',
bottom: 'items-end',
}[position ?? 'bottom']
return ( return (
<Transition.Root show={open} as={Fragment}> <Transition.Root show={open} as={Fragment}>
<Dialog <Dialog
@ -26,7 +33,12 @@ export function Modal(props: {
className="fixed inset-0 z-50 overflow-y-auto" className="fixed inset-0 z-50 overflow-y-auto"
onClose={setOpen} onClose={setOpen}
> >
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:p-0"> <div
className={clsx(
'flex min-h-screen justify-center px-4 pt-4 pb-20 text-center sm:p-0',
positionClass
)}
>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"

View File

@ -64,7 +64,7 @@ export function BottomNavBar() {
item={{ item={{
name: formatMoney(user.balance), name: formatMoney(user.balance),
trackingEventName: 'profile', trackingEventName: 'profile',
href: `/${user.username}?tab=bets`, href: `/${user.username}?tab=trades`,
icon: () => ( icon: () => (
<Avatar <Avatar
className="mx-auto my-1" className="mx-auto my-1"

View File

@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=bets`}> <Link href={`/${user.username}?tab=trades`}>
<a <a
onClick={trackCallback('sidebar: profile')} onClick={trackCallback('sidebar: profile')}
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit bet'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
)} )}

View File

@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money betting" title="Play-money trading"
className="mt-4 max-w-md" className="mt-4 max-w-md"
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/> />
) )

View File

@ -11,7 +11,7 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span> <span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span> <span className="text-xl">Daily loans on your trades</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span> <span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -83,14 +83,14 @@ export function ResolutionPanel(props: {
<div> <div>
{outcome === 'YES' ? ( {outcome === 'YES' ? (
<> <>
Winnings will be paid out to YES bettors. Winnings will be paid out to traders who bought YES.
{/* <br /> {/* <br />
<br /> <br />
You will earn {earnedFees}. */} You will earn {earnedFees}. */}
</> </>
) : outcome === 'NO' ? ( ) : outcome === 'NO' ? (
<> <>
Winnings will be paid out to NO bettors. Winnings will be paid out to traders who bought NO.
{/* <br /> {/* <br />
<br /> <br />
You will earn {earnedFees}. */} You will earn {earnedFees}. */}

View File

@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
size={size} size={size}
color="gradient" color="gradient"
> >
{label ?? 'Sign up to bet!'} {label ?? 'Sign up to predict!'}
</Button> </Button>
) : null ) : null
} }

View File

@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
comment.commentType === 'contract' ? comment.contractId : undefined comment.commentType === 'contract' ? comment.contractId : undefined
const groupId = const groupId =
comment.commentType === 'group' ? comment.groupId : undefined comment.commentType === 'group' ? comment.groupId : undefined
const postId = comment.commentType === 'post' ? comment.postId : undefined
await transact({ await transact({
amount: change, amount: change,
fromId: user.id, fromId: user.id,
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER', toType: 'USER',
token: 'M$', token: 'M$',
category: 'TIP', category: 'TIP',
data: { commentId: comment.id, contractId, groupId }, data: { commentId: comment.id, contractId, groupId, postId },
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
}) })
@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
commentId: comment.id, commentId: comment.id,
contractId, contractId,
groupId, groupId,
postId,
amount: change, amount: change,
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,

View File

@ -260,7 +260,7 @@ export function UserPage(props: { user: User }) {
), ),
}, },
{ {
title: 'Bets', title: 'Trades',
content: ( content: (
<> <>
<BetsList user={user} /> <BetsList user={user} />

View File

@ -0,0 +1,74 @@
import clsx from 'clsx'
import React from 'react'
import { Row } from './layout/row'
import { ConfirmationButton } from './confirmation-button'
import { ExclamationIcon } from '@heroicons/react/solid'
export function WarningConfirmationButton(props: {
warning?: string
onSubmit: () => void
disabled?: boolean
isSubmitting: boolean
openModalButtonClass?: string
submitButtonClassName?: string
}) {
const {
onSubmit,
warning,
disabled,
isSubmitting,
openModalButtonClass,
submitButtonClassName,
} = props
if (!warning) {
return (
<button
className={clsx(
openModalButtonClass,
isSubmitting ? 'loading' : '',
disabled && 'btn-disabled'
)}
onClick={onSubmit}
disabled={disabled}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)
}
return (
<ConfirmationButton
openModalBtn={{
className: clsx(
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: 'Submit',
}}
cancelBtn={{
label: 'Cancel',
className: 'btn-warning',
}}
submitBtn={{
label: 'Submit',
className: clsx(
'border-none btn-sm btn-ghost self-center',
submitButtonClassName
),
}}
onSubmit={onSubmit}
>
<Row className="items-center text-xl">
<ExclamationIcon
className="h-16 w-16 text-yellow-400"
aria-hidden="true"
/>
Whoa, there!
</Row>
<p>{warning}</p>
</ConfirmationButton>
)
}

View File

@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
)} )}
onClick={onClick} onClick={onClick}
> >
Bet Buy
</button> </button>
) )
} }

View File

@ -1,8 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Comment, ContractComment, GroupComment } from 'common/comment' import {
Comment,
ContractComment,
GroupComment,
PostComment,
} from 'common/comment'
import { import {
listenForCommentsOnContract, listenForCommentsOnContract,
listenForCommentsOnGroup, listenForCommentsOnGroup,
listenForCommentsOnPost,
listenForRecentComments, listenForRecentComments,
} from 'web/lib/firebase/comments' } from 'web/lib/firebase/comments'
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
return comments return comments
} }
export const useCommentsOnPost = (postId: string | undefined) => {
const [comments, setComments] = useState<PostComment[] | undefined>()
useEffect(() => {
if (postId) return listenForCommentsOnPost(postId, setComments)
}, [postId])
return comments
}
export const useRecentComments = () => { export const useRecentComments = () => {
const [recentComments, setRecentComments] = useState<Comment[] | undefined>() const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
useEffect(() => listenForRecentComments(setRecentComments), []) useEffect(() => listenForRecentComments(setRecentComments), [])

View File

@ -13,6 +13,7 @@ import {
getUserBetContractsQuery, getUserBetContractsQuery,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
export const useContracts = () => { export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>() const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -96,8 +97,10 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
export const usePrefetchUserBetContracts = (userId: string) => { export const usePrefetchUserBetContracts = (userId: string) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return queryClient.prefetchQuery(['contracts', 'bets', userId], () => return queryClient.prefetchQuery(
getUserBetContracts(userId) ['contracts', 'bets', userId],
() => getUserBetContracts(userId),
{ staleTime: 5 * MINUTE_MS }
) )
} }

View File

@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group' import { Group } from 'common/group'
import { User } from 'common/user' import { User } from 'common/user'
import { import {
getMemberGroups,
GroupMemberDoc, GroupMemberDoc,
groupMembers, groupMembers,
listenForGroup, listenForGroup,
listenForGroupContractDocs, listenForGroupContractDocs,
listenForGroups, listenForGroups,
listenForMemberGroupIds, listenForMemberGroupIds,
listenForMemberGroups,
listenForOpenGroups, listenForOpenGroups,
listGroups, listGroups,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils' import { listenForValues } from 'web/lib/firebase/utils'
import { useQuery } from 'react-query'
export const useGroup = (groupId: string | undefined) => { export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>() const [group, setGroup] = useState<Group | null | undefined>()
@ -49,12 +50,10 @@ export const useOpenGroups = () => {
} }
export const useMemberGroups = (userId: string | null | undefined) => { export const useMemberGroups = (userId: string | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() const result = useQuery(['member-groups', userId ?? ''], () =>
useEffect(() => { getMemberGroups(userId ?? '')
if (userId) )
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups)) return result.data
}, [userId])
return memberGroups
} }
// Note: We cache member group ids in localstorage to speed up the initial load // Note: We cache member group ids in localstorage to speed up the initial load

View File

@ -1,6 +1,6 @@
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DAY_MS, HOUR_MS } from 'common/util/time' import { DAY_MS, HOUR_MS, MINUTE_MS } from 'common/util/time'
import { import {
getPortfolioHistory, getPortfolioHistory,
getPortfolioHistoryQuery, getPortfolioHistoryQuery,
@ -15,8 +15,10 @@ const getCutoff = (period: Period) => {
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => { export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const cutoff = getCutoff(period) const cutoff = getCutoff(period)
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () => return queryClient.prefetchQuery(
getPortfolioHistory(userId, cutoff) ['portfolio-history', userId, cutoff],
() => getPortfolioHistory(userId, cutoff),
{ staleTime: 15 * MINUTE_MS }
) )
} }
@ -24,7 +26,9 @@ export const usePortfolioHistory = (userId: string, period: Period) => {
const cutoff = getCutoff(period) const cutoff = getCutoff(period)
const result = useFirestoreQueryData( const result = useFirestoreQueryData(
['portfolio-history', userId, cutoff], ['portfolio-history', userId, cutoff],
getPortfolioHistoryQuery(userId, cutoff) getPortfolioHistoryQuery(userId, cutoff),
{},
{ staleTime: 15 * MINUTE_MS }
) )
return result.data return result.data
} }

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { import {
listenForTipTxns, listenForTipTxns,
listenForTipTxnsOnGroup, listenForTipTxnsOnGroup,
listenForTipTxnsOnPost,
} from 'web/lib/firebase/txns' } from 'web/lib/firebase/txns'
export type CommentTips = { [userId: string]: number } export type CommentTips = { [userId: string]: number }
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
export function useTipTxns(on: { export function useTipTxns(on: {
contractId?: string contractId?: string
groupId?: string groupId?: string
postId?: string
}): CommentTipMap { }): CommentTipMap {
const [txns, setTxns] = useState<TipTxn[]>([]) const [txns, setTxns] = useState<TipTxn[]>([])
const { contractId, groupId } = on const { contractId, groupId, postId } = on
useEffect(() => { useEffect(() => {
if (contractId) return listenForTipTxns(contractId, setTxns) if (contractId) return listenForTipTxns(contractId, setTxns)
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
}, [contractId, groupId, setTxns]) if (postId) return listenForTipTxnsOnPost(postId, setTxns)
}, [contractId, groupId, postId, setTxns])
return useMemo(() => { return useMemo(() => {
const byComment = groupBy(txns, 'data.commentId') const byComment = groupBy(txns, 'data.commentId')

View File

@ -7,10 +7,15 @@ import {
getUserBetsQuery, getUserBetsQuery,
listenForUserContractBets, listenForUserContractBets,
} from 'web/lib/firebase/bets' } from 'web/lib/firebase/bets'
import { MINUTE_MS } from 'common/util/time'
export const usePrefetchUserBets = (userId: string) => { export const usePrefetchUserBets = (userId: string) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId)) return queryClient.prefetchQuery(
['bets', userId],
() => getUserBets(userId),
{ staleTime: MINUTE_MS }
)
} }
export const useUserBets = (userId: string) => { export const useUserBets = (userId: string) => {

View File

@ -6,7 +6,8 @@ import { useFollows } from './use-follows'
import { useUser } from './use-user' import { useUser } from './use-user'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DocumentData } from 'firebase/firestore' import { DocumentData } from 'firebase/firestore'
import { users, privateUsers } from 'web/lib/firebase/users' import { users, privateUsers, getUsers } from 'web/lib/firebase/users'
import { QueryClient } from 'react-query'
export const useUsers = () => { export const useUsers = () => {
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, { const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
@ -16,6 +17,10 @@ export const useUsers = () => {
return result.data ?? [] return result.data ?? []
} }
const q = new QueryClient()
export const getCachedUsers = async () =>
q.fetchQuery(['users'], getUsers, { staleTime: Infinity })
export const usePrivateUsers = () => { export const usePrivateUsers = () => {
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>( const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
['private users'], ['private users'],

View File

@ -7,12 +7,22 @@ import {
query, query,
setDoc, setDoc,
where, where,
DocumentData,
DocumentReference,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { db } from './init' import { db } from './init'
import { User } from 'common/user' import { User } from 'common/user'
import { Comment, ContractComment, GroupComment } from 'common/comment' import {
Comment,
ContractComment,
GroupComment,
OnContract,
OnGroup,
OnPost,
PostComment,
} from 'common/comment'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react' import { JSONContent } from '@tiptap/react'
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createCommentOnContract( export async function createCommentOnContract(
contractId: string, contractId: string,
content: JSONContent, content: JSONContent,
commenter: User, user: User,
betId?: string, betId?: string,
answerOutcome?: string, answerOutcome?: string,
replyToCommentId?: string replyToCommentId?: string
@ -32,28 +42,20 @@ export async function createCommentOnContract(
const ref = betId const ref = betId
? doc(getCommentsCollection(contractId), betId) ? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId)) : doc(getCommentsCollection(contractId))
// contract slug and question are set via trigger const onContract = {
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'contract', commentType: 'contract',
contractId, contractId,
userId: commenter.id, betId,
content: content, answerOutcome,
createdTime: Date.now(), } as OnContract
userName: commenter.name, return await createComment(
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
betId: betId,
answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId,
})
track('comment', {
contractId, contractId,
commentId: ref.id, onContract,
betId: betId, content,
replyToCommentId: replyToCommentId, user,
}) ref,
return await setDoc(ref, comment) replyToCommentId
)
} }
export async function createCommentOnGroup( export async function createCommentOnGroup(
groupId: string, groupId: string,
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
replyToCommentId?: string replyToCommentId?: string
) { ) {
const ref = doc(getCommentsOnGroupCollection(groupId)) const ref = doc(getCommentsOnGroupCollection(groupId))
const onGroup = { commentType: 'group', groupId: groupId } as OnGroup
return await createComment(
groupId,
onGroup,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnPost(
postId: string,
content: JSONContent,
user: User,
replyToCommentId?: string
) {
const ref = doc(getCommentsOnPostCollection(postId))
const onPost = { postId: postId, commentType: 'post' } as OnPost
return await createComment(
postId,
onPost,
content,
user,
ref,
replyToCommentId
)
}
async function createComment(
surfaceId: string,
extraFields: OnContract | OnGroup | OnPost,
content: JSONContent,
user: User,
ref: DocumentReference<DocumentData>,
replyToCommentId?: string
) {
const comment = removeUndefinedProps({ const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
commentType: 'group',
groupId,
userId: user.id, userId: user.id,
content: content, content: content,
createdTime: Date.now(), createdTime: Date.now(),
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
userUsername: user.username, userUsername: user.username,
userAvatarUrl: user.avatarUrl, userAvatarUrl: user.avatarUrl,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
...extraFields,
}) })
track('group message', {
track(`${extraFields.commentType} message`, {
user, user,
commentId: ref.id, commentId: ref.id,
groupId, surfaceId,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
}) })
return await setDoc(ref, comment) return await setDoc(ref, comment)
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments') return collection(db, 'groups', groupId, 'comments')
} }
function getCommentsOnPostCollection(postId: string) {
return collection(db, 'posts', postId, 'comments')
}
export async function listAllComments(contractId: string) { export async function listAllComments(contractId: string) {
return await getValues<Comment>( return await getValues<Comment>(
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) {
) )
} }
export async function listAllCommentsOnPost(postId: string) {
return await getValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc'))
)
}
export function listenForCommentsOnContract( export function listenForCommentsOnContract(
contractId: string, contractId: string,
setComments: (comments: ContractComment[]) => void setComments: (comments: ContractComment[]) => void
@ -126,6 +175,16 @@ export function listenForCommentsOnGroup(
) )
} }
export function listenForCommentsOnPost(
postId: string,
setComments: (comments: PostComment[]) => void
) {
return listenForValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')),
setComments
)
}
const DAY_IN_MS = 24 * 60 * 60 * 1000 const DAY_IN_MS = 24 * 60 * 60 * 1000
// Define "recent" as "<3 days ago" for now // Define "recent" as "<3 days ago" for now

View File

@ -31,7 +31,7 @@ export const groupMembers = (groupId: string) =>
export const groupContracts = (groupId: string) => export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts') collection(groups, groupId, 'groupContracts')
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true)) const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
const memberGroupsQuery = (userId: string) => export const memberGroupsQuery = (userId: string) =>
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId)) query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
export function groupPath( export function groupPath(
@ -112,6 +112,15 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup) return listenForValue(doc(groups, groupId), setGroup)
} }
export async function getMemberGroups(userId: string) {
const snapshot = await getDocs(memberGroupsQuery(userId))
const groupIds = filterDefined(
snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = await Promise.all(groupIds.map(getGroup))
return filterDefined(groups)
}
export function listenForMemberGroupIds( export function listenForMemberGroupIds(
userId: string, userId: string,
setGroupIds: (groupIds: string[]) => void setGroupIds: (groupIds: string[]) => void

View File

@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
where('data.groupId', '==', groupId) where('data.groupId', '==', groupId)
) )
const getTipsOnPostQuery = (postId: string) =>
query(
txns,
where('category', '==', 'TIP'),
where('data.postId', '==', postId)
)
export function listenForTipTxns( export function listenForTipTxns(
contractId: string, contractId: string,
setTxns: (txns: TipTxn[]) => void setTxns: (txns: TipTxn[]) => void
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns) return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
} }
export function listenForTipTxnsOnPost(
postId: string,
setTxns: (txns: TipTxn[]) => void
) {
return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns)
}
// Find all manalink Txns that are from or to this user // Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) { export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([]) const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])

View File

@ -0,0 +1,19 @@
export default function ChallengeIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={props.className}
viewBox="0 0 24 24"
>
<g>
<polygon points="18.63 15.11 15.37 18.49 3.39 6.44 1.82 1.05 7.02 2.68 18.63 15.11" />
<polygon points="21.16 13.73 22.26 14.87 19.51 17.72 23 21.35 21.41 23 17.91 19.37 15.16 22.23 14.07 21.09 21.16 13.73" />
</g>
<g>
<polygon points="8.6 18.44 5.34 15.06 16.96 2.63 22.15 1 20.58 6.39 8.6 18.44" />
<polygon points="9.93 21.07 8.84 22.21 6.09 19.35 2.59 22.98 1 21.33 4.49 17.7 1.74 14.85 2.84 13.71 9.93 21.07" />
</g>
</svg>
)
}

View File

@ -12,3 +12,7 @@ export function isIOS() {
(navigator.userAgent.includes('Mac') && 'ontouchend' in document) (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
) )
} }
export function isAndroid() {
return navigator.userAgent.includes('Android')
}

View File

@ -9,6 +9,9 @@ module.exports = {
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,
experimental: { experimental: {
images: {
allowFutureImage: true,
},
scrollRestoration: true, scrollRestoration: true,
externalDir: true, externalDir: true,
modularizeImports: { modularizeImports: {
@ -25,7 +28,12 @@ module.exports = {
}, },
}, },
images: { images: {
domains: ['lh3.googleusercontent.com', 'i.imgur.com'], domains: [
'manifold.markets',
'lh3.googleusercontent.com',
'i.imgur.com',
'firebasestorage.googleapis.com',
],
}, },
async redirects() { async redirects() {
return [ return [

View File

@ -5,7 +5,7 @@ import { Comment } from 'common/comment'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { ENV_CONFIG } from 'common/envs/constants' import { DOMAIN, ENV_CONFIG } from 'common/envs/constants'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { richTextToString } from 'common/util/parse' import { richTextToString } from 'common/util/parse'
@ -121,7 +121,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
: closeTime, : closeTime,
question, question,
tags, tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`, url: `https://${DOMAIN}/${creatorUsername}/${slug}`,
pool, pool,
probability, probability,
p, p,

View File

@ -178,7 +178,7 @@ export default function Charity(props: {
className="input input-bordered mb-6 w-full" className="input input-bordered mb-6 w-full"
/> />
</Col> </Col>
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
{filterCharities.map((charity) => ( {filterCharities.map((charity) => (
<CharityCard <CharityCard
charity={charity} charity={charity}
@ -203,18 +203,26 @@ export default function Charity(props: {
></iframe> ></iframe>
</div> </div>
<div className="mt-10 text-gray-500"> <div className="prose mt-10 max-w-none text-gray-500">
<span className="font-semibold">Notes</span> <span className="text-lg font-semibold">Notes</span>
<br /> <ul>
- Don't see your favorite charity? Recommend it by emailing <li>
charity@manifold.markets! Don't see your favorite charity? Recommend it by emailing{' '}
<br /> <a href="mailto:charity@manifold.markets?subject=Add%20Charity">
- Manifold is not affiliated with non-Featured charities; we're just charity@manifold.markets
</a>
!
</li>
<li>
Manifold is not affiliated with non-Featured charities; we're just
fans of their work. fans of their work.
<br /> </li>
- As Manifold itself is a for-profit entity, your contributions will <li>
As Manifold itself is a for-profit entity, your contributions will
not be tax deductible. not be tax deductible.
<br />- Donations + matches are wired once each quarter. </li>
<li>Donations + matches are wired once each quarter.</li>
</ul>
</div> </div>
</Col> </Col>
</Page> </Page>

View File

@ -0,0 +1,55 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ArrangeHome } from 'web/components/arrange-home'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
export default function Home() {
const user = useUser()
useTracking('edit home')
const [homeSections, setHomeSections] = useState(user?.homeSections ?? [])
const updateHomeSections = (newHomeSections: string[]) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
<Row className={'w-full items-center justify-between'}>
<Title text="Edit your home page" />
<DoneButton />
</Row>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</Col>
</Page>
)
}
function DoneButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home">
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
Done
</Button>
</SiteLink>
)
}

View File

@ -1,40 +1,37 @@
import React, { useState } from 'react' import React from 'react'
import Router from 'next/router' import Router from 'next/router'
import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid' import {
PencilIcon,
PlusSmIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search' import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user' import { User } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/components/contract-search' import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group' import { Group } from 'common/group'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { GroupLinkItem } from '../../groups'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { DoubleCarousel } from '../../../components/double-carousel'
import clsx from 'clsx'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' import { getHomeItems } from '../../../components/arrange-home'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = () => {
const creds = await authenticateOnServer(ctx) const user = useUser()
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = useUser() ?? props.auth?.user ?? null
useTracking('view home') useTracking('view home')
@ -42,76 +39,41 @@ const Home = (props: { auth: { user: User } | null }) => {
const groups = useMemberGroups(user?.id) ?? [] const groups = useMemberGroups(user?.id) ?? []
const [homeSections, setHomeSections] = useState( const { sections } = getHomeItems(groups, user?.homeSections ?? [])
user?.homeSections ?? { visible: [], hidden: [] }
)
const { visibleItems } = getHomeItems(groups, homeSections)
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
const [isEditing, setIsEditing] = useState(false)
return ( return (
<Page> <Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]"> <Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}> <Row className={'w-full items-center justify-between'}>
<Title text={isEditing ? 'Edit your home page' : 'Home'} /> <Title className="!mb-0" text="Home" />
<EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} /> <EditButton />
</Row> </Row>
{isEditing ? ( <DailyProfitAndBalance userId={user?.id} />
<>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</>
) : (
<>
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
{visibleItems.map((item) => { {sections.map((item) => {
const { id } = item const { id } = item
if (id === 'your-bets') { if (id === 'daily-movers') {
return ( return <DailyMoversSection key={id} userId={user?.id} />
<SearchSection
key={id}
label={'Your bets'}
sort={'prob-change-day'}
user={user}
yourBets
/>
)
} }
const sort = SORTS.find((sort) => sort.value === id) const sort = SORTS.find((sort) => sort.value === id)
if (sort) if (sort)
return ( return (
<SearchSection <SearchSection
key={id} key={id}
label={sort.label} label={sort.value === 'newest' ? 'New for you' : sort.label}
sort={sort.value} sort={sort.value}
followed={sort.value === 'newest'}
user={user} user={user}
/> />
) )
const group = groups.find((g) => g.id === id) const group = groups.find((g) => g.id === id)
if (group) if (group) return <GroupSection key={id} group={group} user={user} />
return <GroupSection key={id} group={group} user={user} />
return null return null
})} })}
</>
)}
</Col> </Col>
<button <button
type="button" type="button"
@ -129,98 +91,129 @@ const Home = (props: { auth: { user: User } | null }) => {
function SearchSection(props: { function SearchSection(props: {
label: string label: string
user: User | null user: User | null | undefined | undefined
sort: Sort sort: Sort
yourBets?: boolean yourBets?: boolean
followed?: boolean
}) { }) {
const { label, user, sort, yourBets } = props const { label, user, sort, yourBets, followed } = props
const href = `/home?s=${sort}` const href = `/home?s=${sort}`
return ( return (
<Col> <Col>
<SiteLink className="mb-2 text-xl" href={href}> <SiteLink className="mb-2 text-xl" href={href}>
{label} {label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink> </SiteLink>
<ContractSearch <ContractSearch
user={user} user={user}
defaultSort={sort} defaultSort={sort}
additionalFilter={yourBets ? { yourBets: true } : undefined} additionalFilter={
noControls yourBets
// persistPrefix={`experimental-home-${sort}`} ? { yourBets: true }
renderContracts={(contracts, loadMore) => : followed
contracts ? ( ? { followed: true }
<DoubleCarousel
contracts={contracts}
seeMoreUrl={href}
showTime={
sort === 'close-date' || sort === 'resolve-date'
? sort
: undefined : undefined
} }
loadMore={loadMore} noControls
/> maxResults={6}
) : ( persistPrefix={`experimental-home-${sort}`}
<LoadingIndicator />
)
}
/> />
</Col> </Col>
) )
} }
function GroupSection(props: { group: Group; user: User | null }) { function GroupSection(props: {
group: Group
user: User | null | undefined | undefined
}) {
const { group, user } = props const { group, user } = props
return ( return (
<Col> <Col>
<GroupLinkItem className="mb-2 text-xl" group={group} /> <SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
{group.name}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ContractSearch <ContractSearch
user={user} user={user}
defaultSort={'score'} defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
noControls noControls
// persistPrefix={`experimental-home-${group.slug}`} maxResults={6}
renderContracts={(contracts, loadMore) => persistPrefix={`experimental-home-${group.slug}`}
contracts ? (
contracts.length == 0 ? (
<div className="m-2 text-gray-500">No open markets</div>
) : (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={`/group/${group.slug}`}
loadMore={loadMore}
/>
)
) : (
<LoadingIndicator />
)
}
/> />
</Col> </Col>
) )
} }
function EditDoneButton(props: { function DailyMoversSection(props: { userId: string | null | undefined }) {
isEditing: boolean const { userId } = props
setIsEditing: (isEditing: boolean) => void const changes = useProbChanges(userId ?? '')
className?: string
}) {
const { isEditing, setIsEditing, className } = props
return ( return (
<Button <Col className="gap-2">
size="lg" <SiteLink className="text-xl" href={'/daily-movers'}>
color={isEditing ? 'blue' : 'gray-white'} Daily movers{' '}
className={clsx(className, 'flex')} <ArrowSmRightIcon
onClick={() => { className="mb-0.5 inline h-6 w-6 text-gray-500"
setIsEditing(!isEditing) aria-hidden="true"
}} />
> </SiteLink>
{!isEditing && ( <ProbChangeTable changes={changes} />
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> </Col>
)} )
{isEditing ? 'Done' : 'Edit'} }
function EditButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home/edit">
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
Edit
</Button> </Button>
</SiteLink>
)
}
function DailyProfitAndBalance(props: {
userId: string | null | undefined
className?: string
}) {
const { userId, className } = props
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
if (first === undefined || last === undefined) return null
const profit =
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
const balanceChange = last.balance - first.balance
return (
<div className={clsx(className, 'text-lg')}>
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
{profit >= 0 && '+'}
{formatMoney(profit)}
</span>{' '}
profit and{' '}
<span
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
>
{balanceChange >= 0 && '+'}
{formatMoney(balanceChange)}
</span>{' '}
balance today
</div>
) )
} }

View File

@ -231,6 +231,7 @@ export default function GroupPage(props: {
defaultSort={'newest'} defaultSort={'newest'}
defaultFilter={suggestedFilter} defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/> />
) )

View File

@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple reasonText = !simple
? `Bonus for ${ ? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new bettors on` } new traders on`
: 'bonus on' : 'bonus on'
} else if (sourceType === 'tip') { } else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
@ -508,7 +508,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && ( {(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink <MultiUserTransactionLink
userInfos={userLinks} userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'} modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/> />
)} )}
<Row className={'line-clamp-2 flex max-w-xl'}> <Row className={'line-clamp-2 flex max-w-xl'}>

View File

@ -1,12 +1,12 @@
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { postPath, getPostBySlug } from 'web/lib/firebase/posts' import { postPath, getPostBySlug, updatePost } from 'web/lib/firebase/posts'
import { Post } from 'common/post' import { Post } from 'common/post'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { Content } from 'web/components/editor' import { Content, TextEditor, useTextEditor } from 'web/components/editor'
import { getUser, User } from 'web/lib/firebase/users' import { getUser, User } from 'web/lib/firebase/users'
import { ShareIcon } from '@heroicons/react/solid' import { PencilIcon, ShareIcon } from '@heroicons/react/solid'
import clsx from 'clsx' import clsx from 'clsx'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { useState } from 'react' import { useState } from 'react'
@ -16,17 +16,27 @@ import { Col } from 'web/components/layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import Custom404 from 'web/pages/404' import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { listAllCommentsOnPost } from 'web/lib/firebase/comments'
import { PostComment } from 'common/comment'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash'
import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments'
import { useCommentsOnPost } from 'web/hooks/use-comments'
import { useUser } from 'web/hooks/use-user'
import { usePost } from 'web/hooks/use-post'
export async function getStaticProps(props: { params: { slugs: string[] } }) { export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params const { slugs } = props.params
const post = await getPostBySlug(slugs[0]) const post = await getPostBySlug(slugs[0])
const creator = post ? await getUser(post.creatorId) : null const creator = post ? await getUser(post.creatorId) : null
const comments = post && (await listAllCommentsOnPost(post.id))
return { return {
props: { props: {
post: post, post: post,
creator: creator, creator: creator,
comments: comments,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -37,32 +47,41 @@ export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' } return { paths: [], fallback: 'blocking' }
} }
export default function PostPage(props: { post: Post; creator: User }) { export default function PostPage(props: {
post: Post
creator: User
comments: PostComment[]
}) {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
const { creator } = props
const post = usePost(props.post.id) ?? props.post
if (props.post == null) { const tips = useTipTxns({ postId: post.id })
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}`
const updatedComments = useCommentsOnPost(post.id)
const comments = updatedComments ?? props.comments
const user = useUser()
if (post == null) {
return <Custom404 /> return <Custom404 />
} }
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
return ( return (
<Page> <Page>
<div className="mx-auto w-full max-w-3xl "> <div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} /> <Title className="!mt-0 py-4 px-2" text={post.title} />
<Title className="!mt-0" text={props.post.title} />
<Row> <Row>
<Col className="flex-1"> <Col className="flex-1 px-2">
<div className={'inline-flex'}> <div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div> <div className="mr-1 text-gray-500">Created by</div>
<UserLink <UserLink
className="text-neutral" className="text-neutral"
name={props.creator.name} name={creator.name}
username={props.creator.username} username={creator.username}
/> />
</div> </div>
</Col> </Col>
<Col> <Col className="px-2">
<Button <Button
size="lg" size="lg"
color="gray-white" color="gray-white"
@ -88,10 +107,121 @@ export default function PostPage(props: { post: Post; creator: User }) {
<Spacer h={2} /> <Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<div className="form-control w-full py-2"> <div className="form-control w-full py-2">
<Content content={props.post.content} /> {user && user.id === post.creatorId ? (
<RichEditPost post={post} />
) : (
<Content content={post.content} />
)}
</div> </div>
</div> </div>
<Spacer h={4} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity
post={post}
comments={comments}
tips={tips}
user={creator}
/>
</div>
</div> </div>
</Page> </Page>
) )
} }
export function PostCommentsActivity(props: {
post: Post
comments: PostComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { post, comments, user, tips } = props
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<Col className="p-2">
<PostCommentInput post={post} />
{topLevelComments.map((parent) => (
<PostCommentThread
key={parent.id}
user={user}
post={post}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
commentsByUserId={commentsByUserId}
/>
))}
</Col>
)
}
function RichEditPost(props: { post: Post }) {
const { post } = props
const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
defaultValue: post.content,
disabled: isSubmitting,
})
async function savePost() {
if (!editor) return
await updatePost(post, {
content: editor.getJSON(),
})
}
return editing ? (
<>
<TextEditor editor={editor} upload={upload} />
<Spacer h={2} />
<Row className="gap-2">
<Button
onClick={async () => {
setIsSubmitting(true)
await savePost()
setEditing(false)
setIsSubmitting(false)
}}
>
Save
</Button>
<Button color="gray" onClick={() => setEditing(false)}>
Cancel
</Button>
</Row>
</>
) : (
<>
<div className="relative">
<div className="absolute top-0 right-0 z-10 space-x-2">
<Button
color="gray"
size="xs"
onClick={() => {
setEditing(true)
editor?.commands.focus('end')
}}
>
<PencilIcon className="inline h-4 w-4" />
Edit
</Button>
</div>
<Content content={post.content} />
<Spacer h={2} />
</div>
</>
)
}

View File

@ -77,13 +77,21 @@ const Salem = {
const tourneys: Tourney[] = [ const tourneys: Tourney[] = [
{ {
title: 'Cause Exploration Prizes', title: 'Manifold F2P Tournament',
blurb: blurb:
'Which new charity ideas will Open Philanthropy find most promising?', 'Who can amass the most mana starting from a free-to-play (F2P) account?',
award: 'M$100k', award: 'Poem',
endTime: toDate('Sep 9, 2022'), endTime: toDate('Sep 15, 2022'),
groupId: 'cMcpBQ2p452jEcJD2SFw', groupId: '6rrIja7tVW00lUVwtsYS',
}, },
// {
// title: 'Cause Exploration Prizes',
// blurb:
// 'Which new charity ideas will Open Philanthropy find most promising?',
// award: 'M$100k',
// endTime: toDate('Sep 9, 2022'),
// groupId: 'cMcpBQ2p452jEcJD2SFw',
// },
{ {
title: 'Fantasy Football Stock Exchange', title: 'Fantasy Football Stock Exchange',
blurb: 'How many points will each NFL player score this season?', blurb: 'How many points will each NFL player score this season?',
@ -91,13 +99,6 @@ const tourneys: Tourney[] = [
endTime: toDate('Jan 6, 2023'), endTime: toDate('Jan 6, 2023'),
groupId: 'SxGRqXRpV3RAQKudbcNb', groupId: 'SxGRqXRpV3RAQKudbcNb',
}, },
{
title: 'SF 2022 Ballot',
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
award: '',
endTime: toDate('Nov 8, 2022'),
groupId: 'VkWZyS5yxs8XWUJrX9eq',
},
// { // {
// title: 'Clearer Thinking Regrant Project', // title: 'Clearer Thinking Regrant Project',
// blurb: 'Something amazing', // blurb: 'Something amazing',
@ -105,6 +106,27 @@ const tourneys: Tourney[] = [
// endTime: toDate('Sep 22, 2022'), // endTime: toDate('Sep 22, 2022'),
// groupId: '2VsVVFGhKtIdJnQRAXVb', // groupId: '2VsVVFGhKtIdJnQRAXVb',
// }, // },
// Tournaments without awards get featured belows
{
title: 'SF 2022 Ballot',
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
endTime: toDate('Nov 8, 2022'),
groupId: 'VkWZyS5yxs8XWUJrX9eq',
},
{
title: '2024 Democratic Nominees',
blurb: 'How would different Democratic candidates fare in 2024?',
endTime: toDate('Nov 2, 2024'),
groupId: 'gFhjgFVrnYeFYfxhoLNn',
},
{
title: 'Private Tech Companies',
blurb: 'What will these companies exit for?',
endTime: toDate('Dec 31, 2030'),
groupId: 'faNUnphw6Eoq7OJBRJds',
},
] ]
type SectionInfo = { type SectionInfo = {
@ -135,20 +157,23 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
title="Tournaments" title="Tournaments"
description="Win money by betting in forecasting touraments on current events, sports, science, and more" description="Win money by betting in forecasting touraments on current events, sports, science, and more"
/> />
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]"> <Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
{sections.map(({ tourney, slug, numPeople }) => ( {sections.map(
({ tourney, slug, numPeople }) =>
tourney.award && (
<div key={slug}> <div key={slug}>
<SectionHeader <SectionHeader
url={groupPath(slug)} url={groupPath(slug, 'about')}
title={tourney.title} title={tourney.title}
ppl={numPeople} ppl={numPeople}
award={tourney.award} award={tourney.award}
endTime={tourney.endTime} endTime={tourney.endTime}
/> />
<span>{tourney.blurb}</span> <span className="text-gray-500">{tourney.blurb}</span>
<MarketCarousel slug={slug} /> <MarketCarousel slug={slug} />
</div> </div>
))} )
)}
<div> <div>
<SectionHeader <SectionHeader
url={Salem.url} url={Salem.url}
@ -156,9 +181,52 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
award={Salem.award} award={Salem.award}
endTime={Salem.endTime} endTime={Salem.endTime}
/> />
<span>{Salem.blurb}</span> <span className="text-gray-500">{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} /> <ImageCarousel url={Salem.url} images={Salem.images} />
</div> </div>
{/* Title break */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
Featured Groups
</span>
</div>
</div>
{sections.map(
({ tourney, slug, numPeople }) =>
!tourney.award && (
<div key={slug}>
<SectionHeader
url={groupPath(slug, 'about')}
title={tourney.title}
ppl={numPeople}
award={tourney.award}
endTime={tourney.endTime}
/>
<span className="text-gray-500">{tourney.blurb}</span>
<MarketCarousel slug={slug} />
</div>
)
)}
<p className="pb-10 italic text-gray-500">
We'd love to sponsor more tournaments and groups. Have an idea? Ping{' '}
<SiteLink
className="font-semibold"
href="https://discord.com/invite/eHQBNBqXuh"
>
Austin on Discord
</SiteLink>
!
</p>
</Col> </Col>
</Page> </Page>
) )
@ -175,9 +243,7 @@ const SectionHeader = (props: {
return ( return (
<Link href={url}> <Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between"> <a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> <h2 className="text-xl group-hover:underline md:text-3xl">{title}</h2>
{title}
</h2>
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
{!!award && <span className="flex items-center">🏆 {award}</span>} {!!award && <span className="flex items-center">🏆 {award}</span>}
{!!ppl && ( {!!ppl && (
@ -237,7 +303,7 @@ const MarketCarousel = (props: { slug: string }) => {
key={m.id} key={m.id}
contract={m} contract={m}
hideGroupLink hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0" className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
questionClass="line-clamp-3" questionClass="line-clamp-3"
trackingPostfix=" tournament" trackingPostfix=" tournament"
/> />

172
web/posts/post-comments.tsx Normal file
View File

@ -0,0 +1,172 @@
import { track } from '@amplitude/analytics-browser'
import { Editor } from '@tiptap/core'
import clsx from 'clsx'
import { PostComment } from 'common/comment'
import { Post } from 'common/post'
import { User } from 'common/user'
import { Dictionary } from 'lodash'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Avatar } from 'web/components/avatar'
import { CommentInput } from 'web/components/comment-input'
import { Content } from 'web/components/editor'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Tipper } from 'web/components/tipper'
import { UserLink } from 'web/components/user-link'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { createCommentOnPost } from 'web/lib/firebase/comments'
import { firebaseLogin } from 'web/lib/firebase/users'
export function PostCommentThread(props: {
user: User | null | undefined
post: Post
threadComments: PostComment[]
tips: CommentTipMap
parentComment: PostComment
commentsByUserId: Dictionary<PostComment[]>
}) {
const { post, threadComments, tips, parentComment } = props
const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: PostComment) {
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
return (
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
{[parentComment].concat(threadComments).map((comment, commentIdx) => (
<PostComment
key={comment.id}
indent={commentIdx != 0}
post={post}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
{showReply && (
<Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<PostCommentInput
post={post}
parentCommentId={parentComment.id}
replyToUser={replyTo}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
)}
</Col>
)
}
export function PostCommentInput(props: {
post: Post
parentCommentId?: string
replyToUser?: { id: string; username: string }
onSubmitComment?: () => void
}) {
const user = useUser()
const { post, parentCommentId, replyToUser } = props
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId)
props.onSubmitComment?.()
}
return (
<CommentInput
replyToUser={replyToUser}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
/>
)
}
export function PostComment(props: {
post: Post
comment: PostComment
tips: CommentTips
indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: PostComment) => void
}) {
const { post, comment, tips, indent, onReplyClick } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [comment.id, router.asPath])
return (
<Row
id={comment.id}
className={clsx(
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
{/*draw a gray line from the comment to the left:*/}
{indent ? (
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
) : null}
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3">
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={comment.userName}
slug={post.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<Content
className="mt-2 text-[15px] text-gray-700"
content={content || text}
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</Row>
</div>
</Row>
)
}

View File

@ -64,6 +64,8 @@ function putIntoMapAndFetch(data) {
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts' document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
} else if (whichGuesser === 'basic') { } else if (whichGuesser === 'basic') {
document.getElementById('guess-type').innerText = 'How Basic' document.getElementById('guess-type').innerText = 'How Basic'
} else if (whichGuesser === 'commander') {
document.getElementById('guess-type').innerText = 'General Knowledge'
} }
setUpNewGame() setUpNewGame()
} }
@ -156,8 +158,8 @@ function determineIfSkip(card) {
if (card.flavor_name) { if (card.flavor_name) {
return true return true
} }
// don't include racist cards
return card.content_warning return false
} }
function putIntoMap(data) { function putIntoMap(data) {

View File

@ -3,16 +3,16 @@ import requests
import json import json
# add category name here # add category name here
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath'] allCategories = ['counterspell', 'beast', 'burn', 'commander', 'artist'] #, 'terror', 'wrath', 'zombie', 'artifact']
specialCategories = ['set', 'basic'] specialCategories = ['set', 'basic']
def generate_initial_query(category): def generate_initial_query(category):
string_query = 'https://api.scryfall.com/cards/search?q=' string_query = 'https://api.scryfall.com/cards/search?q='
if category == 'counterspell': if category == 'counterspell':
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure' string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure+not%3Adfc'
elif category == 'beast': elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken' string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken+not%3Adfc'
# elif category == 'terror': # elif category == 'terror':
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \ # string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure' # '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
@ -22,11 +22,19 @@ def generate_initial_query(category):
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \ string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \ '%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
'.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \ '.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure' '+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure+not%3Adfc'
elif category == 'commander':
string_query += 'is%3Acommander+%28not%3Adigital+-banned%3Acommander+or+is%3Adigital+legal%3Ahistoricbrawl+or+legal%3Acommander+or+legal%3Abrawl%29'
# elif category == 'zombie':
# string_query += '-type%3Alegendary+type%3Azombie+-type%3Atoken'
# elif category == 'artifact':
# string_query += 't%3Aartifact&order=released&dir=asc&unique=prints&page='
# elif category == 'artist':
# string_query+= 'a%3A"Wylie+Beckert"+or+a%3A“Ernanda+Souza”+or+a%3A"randy+gallegos"+or+a%3A“Amy+Weber”+or+a%3A“Dan+Frazier”+or+a%3A“Thomas+M.+Baxa”+or+a%3A“Phil+Foglio”+or+a%3A“DiTerlizzi”+or+a%3A"steve+argyle"+or+a%3A"Veronique+Meignaud"+or+a%3A"Magali+Villeneuve"+or+a%3A"Michael+Sutfin"+or+a%3A“Volkan+Baǵa”+or+a%3A“Franz+Vohwinkel”+or+a%3A"Nils+Hamm"+or+a%3A"Mark+Poole"+or+a%3A"Carl+Critchlow"+or+a%3A"rob+alexander"+or+a%3A"igor+kieryluk"+or+a%3A“Victor+Adame+Minguez”+or+a%3A"johannes+voss"+or+a%3A"Svetlin+Velinov"+or+a%3A"ron+spencer"+or+a%3A"rk+post"+or+a%3A"kev+walker"+or+a%3A"rebecca+guay"+or+a%3A"seb+mckinnon"+or+a%3A"pete+venters"+or+a%3A"greg+staples"+or+a%3A"Christopher+Moeller"+or+a%3A"christopher+rush"+or+a%3A"Mark+Tedin"'
# add category string query here # add category string query here
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \ string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \ '<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \ '%29+-name%3A%2F%5EA-%2F+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-st%3Amemorabilia' \
'+language%3Aenglish&order=released&dir=asc&unique=prints&page=' '+language%3Aenglish&order=released&dir=asc&unique=prints&page='
print(string_query) print(string_query)
return string_query return string_query
@ -89,21 +97,34 @@ def fetch_special(query):
def to_compact_write_form(smallJson, art_names, response, category): def to_compact_write_form(smallJson, art_names, response, category):
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital', fieldsInCard = ['name', 'image_uris', 'flavor_name', 'reprint', 'frame_effects', 'digital', 'set_type']
'set_type']
data = [] data = []
# write all fields needed in card # write all fields needed in card
for card in response['data']: for card in response['data']:
# do not include racist cards
if 'content_warning' in card and card['content_warning'] == True:
continue
# do not repeat art # do not repeat art
if 'illustration_id' not in card or card['illustration_id'] in art_names: if 'card_faces' in card:
card_face = card['card_faces'][0]
if 'illustration_id' not in card_face or card_face['illustration_id'] in art_names:
continue
else:
art_names.add(card_face['illustration_id'])
elif 'illustration_id' not in card or card['illustration_id'] in art_names:
continue continue
else: else:
art_names.add(card['illustration_id']) art_names.add(card['illustration_id'])
write_card = dict() write_card = dict()
for field in fieldsInCard: for field in fieldsInCard:
# if field == 'name' and category == 'artifact':
# write_card['name'] = card['released_at'].split('-')[0]
if field == 'name' and 'card_faces' in card: if field == 'name' and 'card_faces' in card:
write_card['name'] = card['card_faces'][0]['name'] write_card['name'] = card['card_faces'][0]['name']
elif field == 'image_uris': elif field == 'image_uris':
if 'card_faces' in card and 'image_uris' in card['card_faces'][0]:
write_card['image_uris'] = write_image_uris(card['card_faces'][0]['image_uris'])
else:
write_card['image_uris'] = write_image_uris(card['image_uris']) write_card['image_uris'] = write_image_uris(card['image_uris'])
elif field in card: elif field in card:
write_card[field] = card[field] write_card[field] = card[field]
@ -115,6 +136,9 @@ def to_compact_write_form_special(smallJson, art_names, response, category):
data = [] data = []
# write all fields needed in card # write all fields needed in card
for card in response['data']: for card in response['data']:
# do not include racist cards
if 'content_warning' in card and card['content_warning'] == True:
continue
if category == 'basic': if category == 'basic':
write_card = dict() write_card = dict()
# do not repeat art # do not repeat art
@ -152,9 +176,9 @@ def write_image_uris(card_image_uris):
if __name__ == "__main__": if __name__ == "__main__":
# for category in allCategories: for category in allCategories:
# print(category) print(category)
# fetch_and_write_all(category, generate_initial_query(category)) fetch_and_write_all(category, generate_initial_query(category))
for category in specialCategories: for category in specialCategories:
print(category) print(category)
fetch_and_write_all_special(category, generate_initial_special_query(category)) fetch_and_write_all_special(category, generate_initial_special_query(category))

View File

@ -17,6 +17,14 @@
f.parentNode.insertBefore(j, f) f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG') })(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</script> </script>
<script>
function updateSettingDefault(digital, un, original) {
window.console.log(digital, un, original)
document.getElementById('digital').checked = digital
document.getElementById('un').checked = un
document.getElementById('original').checked = original
}
</script>
<!-- End Google Tag Manager --> <!-- End Google Tag Manager -->
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style type="text/css"> <style type="text/css">
@ -105,6 +113,18 @@
list-style: none; list-style: none;
text-align: right; text-align: right;
} }
.option-row {
display: flex;
align-items: flex-end;
padding-left: 65px;
}
.level-badge {
display: block;
width: 65px;
padding-left: 8px;
padding-bottom: 2px;
}
</style> </style>
</head> </head>
<body> <body>
@ -125,11 +145,13 @@
action="guess.html" action="guess.html"
style="display: flex; flex-direction: column; align-items: center" style="display: flex; flex-direction: column; align-items: center"
> >
<div class="option-row">
<input <input
type="radio" type="radio"
id="counterspell" id="counterspell"
name="whichguesser" name="whichguesser"
value="counterspell" value="counterspell"
onchange="updateSettingDefault(true, true, false)"
checked checked
/> />
<label class="radio-label" for="counterspell"> <label class="radio-label" for="counterspell">
@ -138,18 +160,44 @@
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855" src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
/> />
<h3>Counterspell Guesser</h3></label <h3>Counterspell Guesser</h3></label
><br /> >
<img
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
/>
</div>
<br />
<input type="radio" id="burn" name="whichguesser" value="burn" /> <div class="option-row">
<input
type="radio"
id="burn"
name="whichguesser"
value="burn"
onchange="updateSettingDefault(true, true, false)"
/>
<label class="radio-label" for="burn"> <label class="radio-label" for="burn">
<img <img
class="thumbnail" class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596" src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
/> />
<h3>Match With Hot Singles</h3></label <h3>Match With Hot Singles</h3></label
><br /> >
<img
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
/>
</div>
<br />
<input type="radio" id="beast" name="whichguesser" value="beast" /> <div class="option-row">
<input
type="radio"
id="beast"
name="whichguesser"
value="beast"
onchange="updateSettingDefault(true, true, false)"
/>
<label class="radio-label" for="beast"> <label class="radio-label" for="beast">
<img <img
class="thumbnail" class="thumbnail"
@ -157,16 +205,55 @@
/> />
<h3>Finding Fantastic Beasts</h3></label <h3>Finding Fantastic Beasts</h3></label
> >
<img
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
/>
</div>
<br /> <br />
<input type="radio" id="basic" name="whichguesser" value="basic" /> <div class="option-row">
<input
type="radio"
id="basic"
name="whichguesser"
value="basic"
onchange="updateSettingDefault(true, true, true)"
/>
<label class="radio-label" for="basic"> <label class="radio-label" for="basic">
<img <img
class="thumbnail" class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346" src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e52ed647-bd30-40a5-b648-0b98d1a3fd4a.jpg?1562949575"
/> />
<h3>How Basic</h3></label <h3>How Basic</h3></label
> >
<img
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/af/Expert_level.jpg"
/>
</div>
<br />
<div class="option-row">
<input
type="radio"
id="commander"
name="whichguesser"
value="commander"
onchange="updateSettingDefault(false, false, false)"
/>
<label class="radio-label" for="commander">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/9/d9631cb2-d53b-4401-b53b-29d27bdefc44.jpg?1562770627"
/>
<h3>General Knowledge</h3></label
>
<img
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/0/00/Starter_level.jpg"
/>
</div>
<br /> <br />
<details id="addl-options"> <details id="addl-options">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -60,6 +60,18 @@ module.exports = {
'overflow-wrap': 'anywhere', 'overflow-wrap': 'anywhere',
'word-break': 'break-word', // for Safari 'word-break': 'break-word', // for Safari
}, },
'.only-thumb': {
'pointer-events': 'none',
'&::-webkit-slider-thumb': {
'pointer-events': 'auto !important',
},
'&::-moz-range-thumb': {
'pointer-events': 'auto !important',
},
'&::-ms-thumb': {
'pointer-events': 'auto !important',
},
},
}) })
}), }),
], ],