Merge remote-tracking branch 'remotes/origin/main' into twitch-linking
This commit is contained in:
commit
49a30dc237
|
@ -1,4 +1,4 @@
|
|||
import { sortBy, sum, sumBy } from 'lodash'
|
||||
import { last, sortBy, sum, sumBy } from 'lodash'
|
||||
import { calculatePayout } from './calculate'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
|
@ -36,6 +36,33 @@ export const computeVolume = (contractBets: Bet[], since: number) => {
|
|||
)
|
||||
}
|
||||
|
||||
const calculateProbChangeSince = (descendingBets: Bet[], since: number) => {
|
||||
const newestBet = descendingBets[0]
|
||||
if (!newestBet) return 0
|
||||
|
||||
const betBeforeSince = descendingBets.find((b) => b.createdTime < since)
|
||||
|
||||
if (!betBeforeSince) {
|
||||
const oldestBet = last(descendingBets) ?? newestBet
|
||||
return newestBet.probAfter - oldestBet.probBefore
|
||||
}
|
||||
|
||||
return newestBet.probAfter - betBeforeSince.probAfter
|
||||
}
|
||||
|
||||
export const calculateProbChanges = (descendingBets: Bet[]) => {
|
||||
const now = Date.now()
|
||||
const yesterday = now - DAY_MS
|
||||
const weekAgo = now - 7 * DAY_MS
|
||||
const monthAgo = now - 30 * DAY_MS
|
||||
|
||||
return {
|
||||
day: calculateProbChangeSince(descendingBets, yesterday),
|
||||
week: calculateProbChangeSince(descendingBets, weekAgo),
|
||||
month: calculateProbChangeSince(descendingBets, monthAgo),
|
||||
}
|
||||
}
|
||||
|
||||
export const calculateCreatorVolume = (userContracts: Contract[]) => {
|
||||
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
||||
const monthlyCreatorVolume = computeTotalPool(
|
||||
|
@ -89,12 +116,12 @@ const calculateProfitForPeriod = (
|
|||
return currentProfit
|
||||
}
|
||||
|
||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||
const startingProfit = calculatePortfolioProfit(startingPortfolio)
|
||||
|
||||
return currentProfit - startingProfit
|
||||
}
|
||||
|
||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||
}
|
||||
|
||||
|
@ -102,7 +129,7 @@ export const calculateNewProfit = (
|
|||
portfolioHistory: PortfolioMetrics[],
|
||||
newPortfolio: PortfolioMetrics
|
||||
) => {
|
||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||
const descendingPortfolio = sortBy(
|
||||
portfolioHistory,
|
||||
(p) => p.timestamp
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
|
@ -20,19 +20,31 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
|||
userAvatarUrl?: string
|
||||
} & T
|
||||
|
||||
type OnContract = {
|
||||
export type OnContract = {
|
||||
commentType: 'contract'
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
answerOutcome?: string
|
||||
betId?: string
|
||||
|
||||
// denormalized from contract
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
|
||||
// denormalized from bet
|
||||
betAmount?: number
|
||||
betOutcome?: string
|
||||
}
|
||||
|
||||
type OnGroup = {
|
||||
export type OnGroup = {
|
||||
commentType: 'group'
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type OnPost = {
|
||||
commentType: 'post'
|
||||
postId: string
|
||||
}
|
||||
|
||||
export type ContractComment = Comment<OnContract>
|
||||
export type GroupComment = Comment<OnGroup>
|
||||
export type PostComment = Comment<OnPost>
|
||||
|
|
|
@ -87,6 +87,12 @@ export type CPMM = {
|
|||
pool: { [outcome: string]: number }
|
||||
p: number // probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity: number // in M$
|
||||
prob: number
|
||||
probChanges: {
|
||||
day: number
|
||||
week: number
|
||||
month: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Binary = {
|
||||
|
|
|
@ -12,10 +12,6 @@ export type Group = {
|
|||
aboutPostId?: string
|
||||
chatDisabled?: boolean
|
||||
mostRecentContractAddedTime?: number
|
||||
/** @deprecated - members and contracts now stored as subcollections*/
|
||||
memberIds?: string[] // Deprecated
|
||||
/** @deprecated - members and contracts now stored as subcollections*/
|
||||
contractIds?: string[] // Deprecated
|
||||
}
|
||||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
export const MAX_ABOUT_LENGTH = 140
|
||||
|
|
|
@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
|||
initialProbability: p,
|
||||
p,
|
||||
pool: pool,
|
||||
prob: initialProb,
|
||||
probChanges: { day: 0, week: 0, month: 0 },
|
||||
}
|
||||
|
||||
return system
|
||||
|
|
|
@ -54,6 +54,10 @@ Returns the authenticated user.
|
|||
|
||||
Gets all groups, in no particular order.
|
||||
|
||||
Parameters:
|
||||
- `availableToUserId`: Optional. if specified, only groups that the user can
|
||||
join and groups they've already joined will be returned.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/[slug]`
|
||||
|
@ -62,12 +66,18 @@ Gets a group by its slug.
|
|||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/by-id/[id]`
|
||||
### `GET /v0/group/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/group/by-id/[id]/markets`
|
||||
|
||||
Gets a group's markets by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
Lists all markets, ordered by creation date descending.
|
||||
|
|
|
@ -12,7 +12,9 @@ service cloud.firestore {
|
|||
'taowell@gmail.com',
|
||||
'abc.sinclair@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()
|
||||
.hasOnly(['name', 'content']);
|
||||
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) ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions
|
|||
.doc(comment.betId)
|
||||
.get()
|
||||
bet = betSnapshot.data() as Bet
|
||||
|
||||
answer =
|
||||
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
|
||||
? contract.answers.find((answer) => answer.id === bet?.outcome)
|
||||
: undefined
|
||||
|
||||
await change.ref.update({
|
||||
betOutcome: bet.outcome,
|
||||
betAmount: bet.amount,
|
||||
})
|
||||
}
|
||||
|
||||
const comments = await getValues<ContractComment>(
|
||||
|
|
|
@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
!isFinite(newP) ||
|
||||
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()
|
||||
|
|
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal file
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
// Filling in the bet-based fields on comments.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { zip } from 'lodash'
|
||||
import { initAdmin } from './script-init'
|
||||
import {
|
||||
DocumentCorrespondence,
|
||||
findDiffs,
|
||||
describeDiff,
|
||||
applyDiff,
|
||||
} from './denormalize'
|
||||
import { log } from '../utils'
|
||||
import { Transaction } from 'firebase-admin/firestore'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function getBetComments(transaction: Transaction) {
|
||||
const allComments = await transaction.get(
|
||||
firestore.collectionGroup('comments')
|
||||
)
|
||||
const betComments = allComments.docs.filter((d) => d.get('betId'))
|
||||
log(`Found ${betComments.length} comments associated with bets.`)
|
||||
return betComments
|
||||
}
|
||||
|
||||
async function denormalize() {
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
hasMore = await admin.firestore().runTransaction(async (trans) => {
|
||||
const betComments = await getBetComments(trans)
|
||||
const bets = await Promise.all(
|
||||
betComments.map((doc) =>
|
||||
trans.get(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(doc.get('contractId'))
|
||||
.collection('bets')
|
||||
.doc(doc.get('betId'))
|
||||
)
|
||||
)
|
||||
)
|
||||
log(`Found ${bets.length} bets associated with comments.`)
|
||||
const mapping = zip(bets, betComments)
|
||||
.map(([bet, comment]): DocumentCorrespondence => {
|
||||
return [bet!, [comment!]] // eslint-disable-line
|
||||
})
|
||||
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
|
||||
|
||||
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
|
||||
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
|
||||
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
|
||||
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
|
||||
const diffs = amountDiffs.concat(outcomeDiffs)
|
||||
diffs.slice(0, 500).forEach((d) => {
|
||||
log(describeDiff(d))
|
||||
applyDiff(trans, d)
|
||||
})
|
||||
if (diffs.length > 500) {
|
||||
console.log(`Applying first 500 because of Firestore limit...`)
|
||||
}
|
||||
return diffs.length > 500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
denormalize().catch((e) => console.error(e))
|
||||
}
|
|
@ -9,84 +9,84 @@ const getGroups = async () => {
|
|||
return groups.docs.map((doc) => doc.data() as Group)
|
||||
}
|
||||
|
||||
const createContractIdForGroup = async (
|
||||
groupId: string,
|
||||
contractId: string
|
||||
) => {
|
||||
const firestore = admin.firestore()
|
||||
const now = Date.now()
|
||||
const contractDoc = await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('groupContracts')
|
||||
.doc(contractId)
|
||||
.get()
|
||||
if (!contractDoc.exists)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('groupContracts')
|
||||
.doc(contractId)
|
||||
.create({
|
||||
contractId,
|
||||
createdTime: now,
|
||||
})
|
||||
}
|
||||
// const createContractIdForGroup = async (
|
||||
// groupId: string,
|
||||
// contractId: string
|
||||
// ) => {
|
||||
// const firestore = admin.firestore()
|
||||
// const now = Date.now()
|
||||
// const contractDoc = await firestore
|
||||
// .collection('groups')
|
||||
// .doc(groupId)
|
||||
// .collection('groupContracts')
|
||||
// .doc(contractId)
|
||||
// .get()
|
||||
// if (!contractDoc.exists)
|
||||
// await firestore
|
||||
// .collection('groups')
|
||||
// .doc(groupId)
|
||||
// .collection('groupContracts')
|
||||
// .doc(contractId)
|
||||
// .create({
|
||||
// contractId,
|
||||
// createdTime: now,
|
||||
// })
|
||||
// }
|
||||
|
||||
const createMemberForGroup = async (groupId: string, userId: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const now = Date.now()
|
||||
const memberDoc = await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('groupMembers')
|
||||
.doc(userId)
|
||||
.get()
|
||||
if (!memberDoc.exists)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('groupMembers')
|
||||
.doc(userId)
|
||||
.create({
|
||||
userId,
|
||||
createdTime: now,
|
||||
})
|
||||
}
|
||||
// const createMemberForGroup = async (groupId: string, userId: string) => {
|
||||
// const firestore = admin.firestore()
|
||||
// const now = Date.now()
|
||||
// const memberDoc = await firestore
|
||||
// .collection('groups')
|
||||
// .doc(groupId)
|
||||
// .collection('groupMembers')
|
||||
// .doc(userId)
|
||||
// .get()
|
||||
// if (!memberDoc.exists)
|
||||
// await firestore
|
||||
// .collection('groups')
|
||||
// .doc(groupId)
|
||||
// .collection('groupMembers')
|
||||
// .doc(userId)
|
||||
// .create({
|
||||
// userId,
|
||||
// createdTime: now,
|
||||
// })
|
||||
// }
|
||||
|
||||
// async function convertGroupFieldsToGroupDocuments() {
|
||||
// const groups = await getGroups()
|
||||
// for (const group of groups) {
|
||||
// log('updating group', group.slug)
|
||||
// const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||
// const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
||||
// const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||
// .size
|
||||
// if (
|
||||
// totalMembers === group.memberIds?.length &&
|
||||
// totalContracts === group.contractIds?.length
|
||||
// ) {
|
||||
// log('group already converted', group.slug)
|
||||
// continue
|
||||
// }
|
||||
// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
|
||||
// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
|
||||
// for (const contractId of group.contractIds?.slice(
|
||||
// contractStart,
|
||||
// group.contractIds?.length
|
||||
// ) ?? []) {
|
||||
// await createContractIdForGroup(group.id, contractId)
|
||||
// }
|
||||
// for (const userId of group.memberIds?.slice(
|
||||
// membersStart,
|
||||
// group.memberIds?.length
|
||||
// ) ?? []) {
|
||||
// await createMemberForGroup(group.id, userId)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function convertGroupFieldsToGroupDocuments() {
|
||||
const groups = await getGroups()
|
||||
for (const group of groups) {
|
||||
log('updating group', group.slug)
|
||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
||||
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||
.size
|
||||
if (
|
||||
totalMembers === group.memberIds?.length &&
|
||||
totalContracts === group.contractIds?.length
|
||||
) {
|
||||
log('group already converted', group.slug)
|
||||
continue
|
||||
}
|
||||
const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
|
||||
const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
|
||||
for (const contractId of group.contractIds?.slice(
|
||||
contractStart,
|
||||
group.contractIds?.length
|
||||
) ?? []) {
|
||||
await createContractIdForGroup(group.id, contractId)
|
||||
}
|
||||
for (const userId of group.memberIds?.slice(
|
||||
membersStart,
|
||||
group.memberIds?.length
|
||||
) ?? []) {
|
||||
await createMemberForGroup(group.id, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTotalContractsAndMembers() {
|
||||
const groups = await getGroups()
|
||||
for (const group of groups) {
|
||||
|
@ -101,9 +101,22 @@ async function updateTotalContractsAndMembers() {
|
|||
})
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function removeUnusedMemberAndContractFields() {
|
||||
const groups = await getGroups()
|
||||
for (const group of groups) {
|
||||
log('removing member and contract ids', group.slug)
|
||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||
await groupRef.update({
|
||||
memberIds: admin.firestore.FieldValue.delete(),
|
||||
contractIds: admin.firestore.FieldValue.delete(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
initAdmin()
|
||||
// convertGroupFieldsToGroupDocuments()
|
||||
updateTotalContractsAndMembers()
|
||||
// updateTotalContractsAndMembers()
|
||||
removeUnusedMemberAndContractFields()
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, keyBy, last } from 'lodash'
|
||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, CPMM } from '../../common/contract'
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
|
@ -11,8 +11,10 @@ import {
|
|||
calculateCreatorVolume,
|
||||
calculateNewPortfolioMetrics,
|
||||
calculateNewProfit,
|
||||
calculateProbChanges,
|
||||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -43,11 +45,29 @@ export async function updateMetricsCore() {
|
|||
.filter((contract) => contract.id)
|
||||
.map((contract) => {
|
||||
const contractBets = betsByContract[contract.id] ?? []
|
||||
const descendingBets = sortBy(
|
||||
contractBets,
|
||||
(bet) => bet.createdTime
|
||||
).reverse()
|
||||
|
||||
let cpmmFields: Partial<CPMM> = {}
|
||||
if (contract.mechanism === 'cpmm-1') {
|
||||
const prob = descendingBets[0]
|
||||
? descendingBets[0].probAfter
|
||||
: getProbability(contract)
|
||||
|
||||
cpmmFields = {
|
||||
prob,
|
||||
probChanges: calculateProbChanges(descendingBets),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
...cpmmFields,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
? resolutionDiv
|
||||
: numericValue
|
||||
? numericValueDiv
|
||||
: probabilityDiv
|
||||
: probability
|
||||
? probabilityDiv
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
|
|||
setError: (error: string | undefined) => void
|
||||
minimumAmount?: number
|
||||
disabled?: boolean
|
||||
showSliderOnMobile?: boolean
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
// Needed to focus the amount input
|
||||
|
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
|
|||
onChange,
|
||||
error,
|
||||
setError,
|
||||
showSliderOnMobile: showSlider,
|
||||
disabled,
|
||||
className,
|
||||
inputClassName,
|
||||
|
@ -121,15 +123,28 @@ export function BuyAmountInput(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
<>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
{showSlider && (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={amount ?? 0}
|
||||
onChange={(e) => onAmountChange(parseInt(e.target.value))}
|
||||
className="range range-lg z-40 mb-2 xl:hidden"
|
||||
step="5"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@ export function AnswerBetPanel(props: {
|
|||
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
||||
<Row className="items-center justify-between self-stretch">
|
||||
<div className="text-xl">
|
||||
Bet on {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
</div>
|
||||
|
||||
{!isModal && (
|
||||
|
@ -134,8 +134,9 @@ export function AnswerBetPanel(props: {
|
|||
</Row>
|
||||
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||
Amount
|
||||
<span>(balance: {formatMoney(user?.balance ?? 0)})</span>
|
||||
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
|
||||
</Row>
|
||||
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -144,6 +145,7 @@ export function AnswerBetPanel(props: {
|
|||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
|
@ -204,7 +206,7 @@ export function AnswerBetPanel(props: {
|
|||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
|
|
@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
|
||||
return (
|
||||
<Col className="gap-4 rounded">
|
||||
<Col className="flex-1 gap-2">
|
||||
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
||||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
|
@ -152,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||
Bet Amount
|
||||
<span className={'sm:hidden'}>
|
||||
(balance: {formatMoney(user?.balance ?? 0)})
|
||||
Balance: {formatMoney(user?.balance ?? 0)}
|
||||
</span>
|
||||
</Row>{' '}
|
||||
<BuyAmountInput
|
||||
|
@ -162,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
setError={setAmountError}
|
||||
minimumAmount={1}
|
||||
disabled={isSubmitting}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
</Col>
|
||||
<Col className="gap-3">
|
||||
|
@ -205,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
disabled={!canSubmit}
|
||||
onClick={withTracking(submitAnswer, 'submit answer')}
|
||||
>
|
||||
Submit answer & buy
|
||||
Submit
|
||||
</button>
|
||||
) : (
|
||||
text && (
|
||||
|
|
|
@ -12,7 +12,7 @@ import { User } from 'common/user'
|
|||
import { Group } from 'common/group'
|
||||
|
||||
export function ArrangeHome(props: {
|
||||
user: User | null
|
||||
user: User | null | undefined
|
||||
homeSections: { visible: string[]; hidden: string[] }
|
||||
setHomeSections: (homeSections: {
|
||||
visible: string[]
|
||||
|
@ -30,7 +30,6 @@ export function ArrangeHome(props: {
|
|||
return (
|
||||
<DragDropContext
|
||||
onDragEnd={(e) => {
|
||||
console.log('drag end', e)
|
||||
const { destination, source, draggableId } = e
|
||||
if (!destination) return
|
||||
|
||||
|
@ -112,7 +111,7 @@ export const getHomeItems = (
|
|||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'Newest', id: 'newest' },
|
||||
{ label: 'Close date', id: 'close-date' },
|
||||
{ label: 'Your bets', id: 'your-bets' },
|
||||
{ label: 'Your trades', id: 'your-bets' },
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
|
|
|
@ -2,6 +2,7 @@ import Router from 'next/router'
|
|||
import clsx from 'clsx'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/future/image'
|
||||
|
||||
export function Avatar(props: {
|
||||
username?: string
|
||||
|
@ -14,6 +15,7 @@ export function Avatar(props: {
|
|||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
const sizeInPx = s * 4
|
||||
|
||||
const onClick =
|
||||
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"
|
||||
// item with a fake grey user circle guy even if you aren't signed in
|
||||
return avatarUrl ? (
|
||||
<img
|
||||
<Image
|
||||
width={sizeInPx}
|
||||
height={sizeInPx}
|
||||
className={clsx(
|
||||
'flex-shrink-0 rounded-full bg-white object-cover',
|
||||
`w-${s} h-${s}`,
|
||||
|
|
|
@ -35,10 +35,13 @@ export default function BetButton(props: {
|
|||
{user ? (
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
Bet
|
||||
Predict
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
|
|
@ -281,7 +281,7 @@ function BuyPanel(props: {
|
|||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
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
|
||||
)}`}
|
||||
/>
|
||||
|
@ -310,9 +310,10 @@ function BuyPanel(props: {
|
|||
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||
Amount
|
||||
<span className={'xl:hidden'}>
|
||||
(balance: {formatMoney(user?.balance ?? 0)})
|
||||
Balance: {formatMoney(user?.balance ?? 0)}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -321,6 +322,7 @@ function BuyPanel(props: {
|
|||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{warning}
|
||||
|
@ -377,11 +379,11 @@ function BuyPanel(props: {
|
|||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit bet'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
|
||||
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -567,7 +569,7 @@ function LimitOrderPanel(props: {
|
|||
<Row className="mt-1 items-center gap-4">
|
||||
<Col className="gap-2">
|
||||
<div className="relative ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -578,7 +580,7 @@ function LimitOrderPanel(props: {
|
|||
</Col>
|
||||
<Col className="gap-2">
|
||||
<div className="ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -606,9 +608,10 @@ function LimitOrderPanel(props: {
|
|||
Max amount<span className="ml-1 text-red-500">*</span>
|
||||
</span>
|
||||
<span className={'xl:hidden'}>
|
||||
(balance: {formatMoney(user?.balance ?? 0)})
|
||||
Balance: {formatMoney(user?.balance ?? 0)}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -616,6 +619,7 @@ function LimitOrderPanel(props: {
|
|||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
|
@ -746,15 +750,18 @@ function QuickOrLimitBet(props: {
|
|||
|
||||
return (
|
||||
<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 sm:text-4xl">
|
||||
Predict
|
||||
</div>
|
||||
{!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
|
||||
selected={!isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(false)
|
||||
track('select quick order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Quick
|
||||
</PillButton>
|
||||
|
@ -764,6 +771,7 @@ function QuickOrLimitBet(props: {
|
|||
setIsLimitOrder(true)
|
||||
track('select limit order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Limit
|
||||
</PillButton>
|
||||
|
|
|
@ -161,7 +161,7 @@ export function BetsList(props: { user: User }) {
|
|||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
||||
return (
|
||||
<Col className="mt-6">
|
||||
<Col>
|
||||
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
|
||||
<Row className="gap-8">
|
||||
<Col>
|
||||
|
|
|
@ -5,19 +5,19 @@ export function PillButton(props: {
|
|||
selected: boolean
|
||||
onSelect: () => void
|
||||
color?: string
|
||||
big?: boolean
|
||||
xs?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { children, selected, onSelect, color, big } = props
|
||||
const { children, selected, onSelect, color, xs } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
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
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
|
|
@ -33,12 +33,12 @@ export function Carousel(props: {
|
|||
}, 500)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(onScroll, [])
|
||||
useEffect(onScroll, [children])
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<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}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
|
|
|
@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
|
|||
setErrorText('')
|
||||
}, [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 = () => {
|
||||
setLoading(true)
|
||||
|
|
|
@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label'
|
|||
import { QRCode } from '../qr-code'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { FIXED_ANTE } from 'common/economy'
|
||||
|
@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea'
|
|||
import { useTextEditor } from 'web/components/editor'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
|
@ -110,9 +110,8 @@ function CreateChallengeForm(props: {
|
|||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||
const defaultExpire = 'week'
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
|
||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||
|
@ -158,7 +157,7 @@ function CreateChallengeForm(props: {
|
|||
<Textarea
|
||||
placeholder="e.g. Will a Democrat be the next president?"
|
||||
className="input input-bordered mt-1 w-full resize-none"
|
||||
autoFocus={!isMobile}
|
||||
autoFocus={true}
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={challengeInfo.question}
|
||||
onChange={(e) =>
|
||||
|
@ -171,7 +170,7 @@ function CreateChallengeForm(props: {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2">
|
||||
<Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
|
||||
<Col>
|
||||
<div>You'll bet:</div>
|
||||
<Row
|
||||
|
@ -186,7 +185,9 @@ function CreateChallengeForm(props: {
|
|||
return {
|
||||
...m,
|
||||
amount: newAmount ?? 0,
|
||||
acceptorAmount: newAmount ?? 0,
|
||||
acceptorAmount: editingAcceptorAmount
|
||||
? m.acceptorAmount
|
||||
: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -197,7 +198,7 @@ function CreateChallengeForm(props: {
|
|||
<span className={''}>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
||||
</Row>
|
||||
<Row className={'max-w-xs justify-end'}>
|
||||
<Row className={'mt-3 max-w-xs justify-end'}>
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
onClick={() =>
|
||||
|
@ -212,18 +213,50 @@ function CreateChallengeForm(props: {
|
|||
<SwitchVerticalIcon className={'h-6 w-6'} />
|
||||
</Button>
|
||||
</Row>
|
||||
<Row className={'items-center'}>If they bet:</Row>
|
||||
<Row
|
||||
className={'max-w-xs items-center justify-between gap-4 pr-3'}
|
||||
>
|
||||
<div className={'w-32 sm:mr-1'}>
|
||||
<AmountInput
|
||||
amount={challengeInfo.acceptorAmount || undefined}
|
||||
onChange={(newAmount) => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
acceptorAmount: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
</div>
|
||||
<span>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</Col>
|
||||
<Row className={'items-center'}>If they bet:</Row>
|
||||
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
||||
<div className={'mt-1 w-32 sm:mr-1'}>
|
||||
<span className={'ml-2 font-bold'}>
|
||||
{formatMoney(challengeInfo.acceptorAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<span>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</Col>
|
||||
{contract && (
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
|
|
175
web/components/comment-input.tsx
Normal file
175
web/components/comment-input.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
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 { useWindowSize } from 'web/hooks/use-window-size'
|
||||
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
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -43,10 +43,13 @@ export const SORTS = [
|
|||
{ label: 'Trending', value: 'score' },
|
||||
{ label: 'Most traded', value: 'most-traded' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: '24h change', value: 'prob-change-day' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Subsidy', value: 'liquidity' },
|
||||
{ label: 'Close date', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
{ label: 'Highest %', value: 'prob-descending' },
|
||||
{ label: 'Lowest %', value: 'prob-ascending' },
|
||||
] as const
|
||||
|
||||
export type Sort = typeof SORTS[number]['value']
|
||||
|
@ -66,6 +69,7 @@ type AdditionalFilter = {
|
|||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
yourBets?: boolean
|
||||
followed?: boolean
|
||||
}
|
||||
|
||||
export function ContractSearch(props: {
|
||||
|
@ -85,6 +89,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam?: boolean
|
||||
isWholePage?: boolean
|
||||
noControls?: boolean
|
||||
maxResults?: number
|
||||
renderContracts?: (
|
||||
contracts: Contract[] | undefined,
|
||||
loadMore: () => void
|
||||
|
@ -104,6 +109,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam,
|
||||
isWholePage,
|
||||
noControls,
|
||||
maxResults,
|
||||
renderContracts,
|
||||
} = props
|
||||
|
||||
|
@ -186,7 +192,8 @@ export function ContractSearch(props: {
|
|||
const contracts = state.pages
|
||||
.flat()
|
||||
.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) {
|
||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||
|
@ -289,6 +296,19 @@ function ContractSearchControls(props: {
|
|||
const pillGroups: { name: string; slug: string }[] =
|
||||
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 = [
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
|
@ -301,6 +321,7 @@ function ContractSearchControls(props: {
|
|||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
...(additionalFilter?.followed ? personalFilters : []),
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
|
@ -317,17 +338,7 @@ function ContractSearchControls(props: {
|
|||
state.pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${state.pillFilter}`
|
||||
: '',
|
||||
state.pillFilter === 'personal'
|
||||
? // 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 === 'personal' ? personalFilters : []),
|
||||
state.pillFilter === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
|
@ -438,7 +449,7 @@ function ContractSearchControls(props: {
|
|||
selected={state.pillFilter === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
Your trades
|
||||
</PillButton>
|
||||
)}
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip'
|
|||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
className?: string
|
||||
questionClass?: string
|
||||
|
@ -45,7 +44,6 @@ export function ContractCard(props: {
|
|||
trackingPostfix?: string
|
||||
}) {
|
||||
const {
|
||||
showHotVolume,
|
||||
showTime,
|
||||
className,
|
||||
questionClass,
|
||||
|
@ -147,7 +145,6 @@ export function ContractCard(props: {
|
|||
<AvatarDetails contract={contract} short={true} className="md:hidden" />
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
|
@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date'
|
|||
|
||||
export function MiscDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showTime, hideGroupLink } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
closeTime,
|
||||
isResolved,
|
||||
createdTime,
|
||||
resolutionTime,
|
||||
} = contract
|
||||
const { contract, showTime, hideGroupLink } = props
|
||||
const { volume, closeTime, isResolved, createdTime, resolutionTime } =
|
||||
contract
|
||||
|
||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showTime === 'close-date' ? (
|
||||
{showTime === 'close-date' ? (
|
||||
<Row className="gap-0.5 whitespace-nowrap">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
|
@ -306,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
|
|||
<Tooltip
|
||||
text={`${formatMoney(
|
||||
volume
|
||||
)} bet - ${uniqueBettors} unique bettors`}
|
||||
)} bet - ${uniqueBettors} unique traders`}
|
||||
>
|
||||
{volumeTranslation}
|
||||
</Tooltip>
|
||||
|
@ -369,7 +357,7 @@ function EditableCloseDate(props: {
|
|||
return (
|
||||
<>
|
||||
{isEditingCloseTime ? (
|
||||
<Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1">
|
||||
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered shrink-0"
|
||||
|
|
|
@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
|
|||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Bettors</td>
|
||||
<td>Traders</td>
|
||||
<td>{bettorsCount}</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
title="🏅 Top traders"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
@ -109,10 +109,6 @@ export function ContractTopTrades(props: {
|
|||
betsBySameUser={[betsById[topCommentId]]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{commentsById[topCommentId].userName} made{' '}
|
||||
{formatMoney(profitById[topCommentId] || 0)}!
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
@ -120,11 +116,11 @@ export function ContractTopTrades(props: {
|
|||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Smartest money" className="!mt-0" />
|
||||
<Title text="💸 Best bet" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedBet contract={contract} bet={betsById[topBetId]} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<div className="mt-2 ml-2 text-sm text-gray-500">
|
||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -116,13 +116,13 @@ export function ContractTabs(props: {
|
|||
badge: `${comments.length}`,
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
title: 'Trades',
|
||||
content: betActivity,
|
||||
badge: `${visibleBets.length}`,
|
||||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
: [{ title: 'Your trades', content: yourTrades }]),
|
||||
]}
|
||||
/>
|
||||
{!user ? (
|
||||
|
|
|
@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
|
|||
additionalFilter={{
|
||||
creatorId: creator.id,
|
||||
}}
|
||||
persistPrefix={`user-${creator.id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
|
||||
|
||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
@ -42,7 +43,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
/>
|
||||
<span>Share</span>
|
||||
</Col>
|
||||
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
|
@ -50,17 +50,21 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showChallenge && (
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
className={'flex hidden max-w-xs self-center sm:inline-block'}
|
||||
className="max-w-xs self-center"
|
||||
onClick={withTracking(
|
||||
() => setOpenCreateChallengeModal(true),
|
||||
'click challenge button'
|
||||
)}
|
||||
>
|
||||
<span>⚔️ Challenge</span>
|
||||
<Col className="items-center sm:flex-row">
|
||||
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
|
||||
<span>Challenge</span>
|
||||
</Col>
|
||||
<CreateChallengeModal
|
||||
isOpen={openCreateChallengeModal}
|
||||
setOpen={setOpenCreateChallengeModal}
|
||||
|
|
|
@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
|
|||
return (
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={'mb-1'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
>
|
||||
<Col className={'items-center sm:flex-row sm:gap-x-2'}>
|
||||
<Col className={'items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'h-6 w-6',
|
||||
'h-[24px] w-5 sm:mr-2',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.id) ||
|
||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||
|
|
98
web/components/contract/prob-change-table.tsx
Normal file
98
web/components/contract/prob-change-table.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import clsx from 'clsx'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { linkClass, SiteLink } from '../site-link'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ProbChangeTable(props: { userId: string | undefined }) {
|
||||
const { userId } = props
|
||||
|
||||
const changes = useProbChanges(userId ?? '')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (!changes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const count = expanded ? 16 : 4
|
||||
|
||||
const { positiveChanges, negativeChanges } = changes
|
||||
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
|
||||
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
|
||||
const filteredChanges = [
|
||||
...filteredPositiveChanges,
|
||||
...filteredNegativeChanges,
|
||||
]
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<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="flex-1 divide-y">
|
||||
{filteredChanges.slice(0, count / 2).map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredChanges.slice(count / 2).map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
<div
|
||||
className={clsx(linkClass, 'cursor-pointer self-end')}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProbChange(props: {
|
||||
contract: CPMMContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const {
|
||||
probChanges: { day: change },
|
||||
} = contract
|
||||
|
||||
const color =
|
||||
change > 0
|
||||
? 'text-green-500'
|
||||
: change < 0
|
||||
? 'text-red-500'
|
||||
: 'text-gray-600'
|
||||
|
||||
const str =
|
||||
change === 0
|
||||
? '+0%'
|
||||
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
|
||||
return <div className={clsx(className, color)}>{str}</div>
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from './button'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
|
||||
export const CreateQuestionButton = () => {
|
||||
return (
|
||||
<Link href="/create" passHref>
|
||||
<Button color="gradient" size="xl" className="mt-4">
|
||||
<SiteLink href="/create">
|
||||
<Button color="gradient" size="xl" className="mt-4 w-full">
|
||||
Create a market
|
||||
</Button>
|
||||
</Link>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
|
|||
|
||||
export function DoubleCarousel(props: {
|
||||
contracts: Contract[]
|
||||
seeMoreUrl?: string
|
||||
showTime?: ShowTime
|
||||
loadMore?: () => void
|
||||
}) {
|
||||
|
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
|
|||
? range(0, Math.floor(contracts.length / 2)).map((col) => {
|
||||
const i = col * 2
|
||||
return (
|
||||
<Col key={contracts[i].id}>
|
||||
<Col className="snap-start scroll-m-4" key={contracts[i].id}>
|
||||
<ContractCard
|
||||
contract={contracts[i]}
|
||||
className="mb-2 w-96 shrink-0"
|
||||
|
|
|
@ -254,7 +254,7 @@ export function RichContent(props: {
|
|||
extensions: [
|
||||
StarterKit,
|
||||
smallImage ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
||||
DisplayMention,
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
|
|||
import { FeedBet } from './feed-bets'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
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 { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
className="mb-5"
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CommentInput,
|
||||
ContractCommentInput,
|
||||
FeedComment,
|
||||
getMostRecentCommentableBet,
|
||||
} 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"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByCurrentUser={commentsByCurrentUser}
|
||||
|
|
|
@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import {
|
||||
createCommentOnContract,
|
||||
MAX_COMMENT_LENGTH,
|
||||
} from 'web/lib/firebase/comments'
|
||||
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
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 { Tipper } from '../tipper'
|
||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Content, TextEditor, useTextEditor } from '../editor'
|
||||
|
||||
import { Content } from '../editor'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { CommentInput } from '../comment-input'
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
user: User | null | undefined
|
||||
|
@ -90,14 +86,16 @@ export function FeedCommentThread(props: {
|
|||
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
parentAnswerOutcome={parentComment.answerOutcome}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
|
@ -125,15 +123,12 @@ export function FeedComment(props: {
|
|||
} = props
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
let betOutcome: string | undefined,
|
||||
bought: string | undefined,
|
||||
money: string | undefined
|
||||
|
||||
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
|
||||
if (matchedBet) {
|
||||
betOutcome = matchedBet.outcome
|
||||
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
|
||||
money = formatMoney(Math.abs(matchedBet.amount))
|
||||
const betOutcome = comment.betOutcome
|
||||
let bought: string | undefined
|
||||
let money: string | undefined
|
||||
if (comment.betAmount != null) {
|
||||
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
|
||||
money = formatMoney(Math.abs(comment.betAmount))
|
||||
}
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
|
@ -148,7 +143,7 @@ export function FeedComment(props: {
|
|||
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
||||
contract,
|
||||
comment.createdTime,
|
||||
matchedBet ? [] : betsBySameUser
|
||||
comment.betId ? [] : betsBySameUser
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -175,7 +170,7 @@ export function FeedComment(props: {
|
|||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
{!matchedBet &&
|
||||
{!comment.betId != null &&
|
||||
userPosition > 0 &&
|
||||
contract.outcomeType !== 'NUMERIC' && (
|
||||
<>
|
||||
|
@ -194,7 +189,6 @@ export function FeedComment(props: {
|
|||
of{' '}
|
||||
<OutcomeLabel
|
||||
outcome={betOutcome ? betOutcome : ''}
|
||||
value={(matchedBet as any).value}
|
||||
contract={contract}
|
||||
truncate="short"
|
||||
/>
|
||||
|
@ -271,67 +265,76 @@ function CommentStatus(props: {
|
|||
)
|
||||
}
|
||||
|
||||
//TODO: move commentinput and comment input text area into their own files
|
||||
export function CommentInput(props: {
|
||||
export function ContractCommentInput(props: {
|
||||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
className?: string
|
||||
parentAnswerOutcome?: string | undefined
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: () => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
className,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyToUser,
|
||||
onSubmitComment,
|
||||
} = 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)
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
user,
|
||||
parentAnswerOutcome
|
||||
)
|
||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||
|
||||
async function submitComment(betId: string | undefined) {
|
||||
async function onSubmitComment(editor: Editor, betId: string | undefined) {
|
||||
if (!user) {
|
||||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
await createCommentOnContract(
|
||||
contract.id,
|
||||
props.contract.id,
|
||||
editor.getJSON(),
|
||||
user,
|
||||
betId,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId
|
||||
props.parentAnswerOutcome,
|
||||
props.parentCommentId
|
||||
)
|
||||
onSubmitComment?.()
|
||||
setIsSubmitting(false)
|
||||
props.onSubmitComment?.()
|
||||
}
|
||||
|
||||
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(
|
||||
contract,
|
||||
Date.now(),
|
||||
|
@ -340,158 +343,36 @@ export function CommentInput(props: {
|
|||
|
||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<Row className={clsx(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">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
||||
<div className="mb-1 text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
||||
/>
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||
<>
|
||||
{"You're"}
|
||||
<CommentStatus
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={
|
||||
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
|
||||
prob={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? getProbability(contract)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||
<>
|
||||
{"You're"}
|
||||
<CommentStatus
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
prob={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? getProbability(contract)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CommentInputTextArea
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
replyToUser={replyToUser}
|
||||
user={user}
|
||||
submitComment={submitComment}
|
||||
isSubmitting={isSubmitting}
|
||||
presetId={id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
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(
|
||||
contract: Contract,
|
||||
createdTime: number,
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
CheckIcon,
|
||||
PlusCircleIcon,
|
||||
SelectorIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||
|
@ -12,6 +13,7 @@ import { useState } from 'react'
|
|||
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
||||
export function GroupSelector(props: {
|
||||
selectedGroup: Group | undefined
|
||||
|
@ -28,13 +30,27 @@ export function GroupSelector(props: {
|
|||
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||
const [query, setQuery] = useState('')
|
||||
const openGroups = useOpenGroups()
|
||||
const memberGroups = useMemberGroups(creator?.id)
|
||||
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
|
||||
const availableGroups = openGroups
|
||||
.concat(
|
||||
(useMemberGroups(creator?.id) ?? []).filter(
|
||||
(memberGroups ?? []).filter(
|
||||
(g) => !openGroups.map((og) => og.id).includes(g.id)
|
||||
)
|
||||
)
|
||||
.filter((group) => !ignoreGroupIds?.includes(group.id))
|
||||
.sort((a, b) => b.totalContracts - a.totalContracts)
|
||||
// put the groups the user is a member of first
|
||||
.sort((a, b) => {
|
||||
if (memberGroupIds.includes(a.id)) {
|
||||
return -1
|
||||
}
|
||||
if (memberGroupIds.includes(b.id)) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const filteredGroups = availableGroups.filter((group) =>
|
||||
searchInAny(query, group.name)
|
||||
)
|
||||
|
@ -96,7 +112,7 @@ export function GroupSelector(props: {
|
|||
value={group}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9',
|
||||
'relative h-12 cursor-pointer select-none py-2 pr-6',
|
||||
active ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)
|
||||
}
|
||||
|
@ -115,11 +131,28 @@ export function GroupSelector(props: {
|
|||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-5 mt-1 block truncate',
|
||||
'ml-3 mt-1 block flex flex-row justify-between',
|
||||
selected && 'font-semibold'
|
||||
)}
|
||||
>
|
||||
{group.name}
|
||||
<Row className={'items-center gap-1 truncate pl-5'}>
|
||||
{memberGroupIds.includes(group.id) && (
|
||||
<UserIcon
|
||||
className={'text-primary h-4 w-4 shrink-0'}
|
||||
/>
|
||||
)}
|
||||
{group.name}
|
||||
</Row>
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white',
|
||||
group.totalContracts > 99 ? 'w-[2.1rem]' : ''
|
||||
)}
|
||||
>
|
||||
{group.totalContracts > 99
|
||||
? '99+'
|
||||
: group.totalContracts}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -107,7 +107,7 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
onClick={firebaseLogin}
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
>
|
||||
Login to Join
|
||||
Login to follow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
)}
|
||||
onClick={withTracking(onLeaveGroup, 'leave group')}
|
||||
>
|
||||
Leave
|
||||
Unfollow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -144,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
onClick={withTracking(onJoinGroup, 'join group')}
|
||||
>
|
||||
Join
|
||||
Follow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
|||
<div className="m-4 max-w-[550px] self-center">
|
||||
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
|
||||
<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">
|
||||
anything!
|
||||
</span>
|
||||
market
|
||||
</span>{' '}
|
||||
for every question
|
||||
</div>
|
||||
</h1>
|
||||
<Spacer h={6} />
|
||||
<div className="mb-4 px-2 ">
|
||||
Create a play-money prediction market on any topic you care about
|
||||
and bet with your friends on what will happen!
|
||||
Create a play-money prediction market on any topic you care about.
|
||||
Trade with your friends to forecast the future.
|
||||
<br />
|
||||
{/* <br />
|
||||
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
|
||||
<SiteLink className="font-semibold" href="/charity">
|
||||
favorite charity.
|
||||
</SiteLink>
|
||||
<br /> */}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer h={6} />
|
||||
|
|
|
@ -64,7 +64,7 @@ export function BottomNavBar() {
|
|||
item={{
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=bets`,
|
||||
href: `/${user.username}?tab=trades`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
|
|
|
@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
|
|||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}?tab=bets`}>
|
||||
<Link href={`/${user.username}?tab=trades`}>
|
||||
<a
|
||||
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"
|
||||
|
|
|
@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
|
|||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit bet'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
|
|||
|
||||
export const PlayMoneyDisclaimer = () => (
|
||||
<InfoBox
|
||||
title="Play-money betting"
|
||||
title="Play-money trading"
|
||||
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!"
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time'
|
|||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
mode: 'value' | 'profit'
|
||||
height?: number
|
||||
includeTime?: boolean
|
||||
}) {
|
||||
const { portfolioHistory, height, includeTime } = props
|
||||
const { portfolioHistory, height, includeTime, mode } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
const profit = value - totalDeposits
|
||||
|
||||
return {
|
||||
x: new Date(p.timestamp),
|
||||
y: p.balance + p.investmentValue,
|
||||
x: new Date(timestamp),
|
||||
y: mode === 'value' ? value : profit,
|
||||
}
|
||||
})
|
||||
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||
|
|
|
@ -5,6 +5,7 @@ import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
|||
import { Period } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
|
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
|
|||
return <></>
|
||||
}
|
||||
|
||||
const { balance, investmentValue } = lastPortfolioMetrics
|
||||
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
|
||||
const totalValue = balance + investmentValue
|
||||
const totalProfit = totalValue - totalDeposits
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="gap-8">
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
<div className="text-sm text-gray-500">Profit</div>
|
||||
<div className="text-lg">{formatMoney(totalProfit)}</div>
|
||||
</Col>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
|
@ -42,6 +44,7 @@ export const PortfolioValueSection = memo(
|
|||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="monthly">Last Month</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
<option value="daily">Last 24h</option>
|
||||
</select>
|
||||
|
@ -49,6 +52,17 @@ export const PortfolioValueSection = memo(
|
|||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="profit"
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
</Col>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="value"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ export function LoansModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<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'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
|
@ -83,14 +83,14 @@ export function ResolutionPanel(props: {
|
|||
<div>
|
||||
{outcome === 'YES' ? (
|
||||
<>
|
||||
Winnings will be paid out to YES bettors.
|
||||
Winnings will be paid out to traders who bought YES.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'NO' ? (
|
||||
<>
|
||||
Winnings will be paid out to NO bettors.
|
||||
Winnings will be paid out to traders who bought NO.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
|
|
|
@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
|
|||
size={size}
|
||||
color="gradient"
|
||||
>
|
||||
{label ?? 'Sign up to bet!'}
|
||||
{label ?? 'Sign up to predict!'}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||
const groupId =
|
||||
comment.commentType === 'group' ? comment.groupId : undefined
|
||||
const postId = comment.commentType === 'post' ? comment.postId : undefined
|
||||
await transact({
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
|
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
toType: 'USER',
|
||||
token: 'M$',
|
||||
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`,
|
||||
})
|
||||
|
||||
|
@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
commentId: comment.id,
|
||||
contractId,
|
||||
groupId,
|
||||
postId,
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
toId: comment.userId,
|
||||
|
|
|
@ -168,62 +168,63 @@ export function UserPage(props: { user: User }) {
|
|||
<Spacer h={4} />
|
||||
</>
|
||||
)}
|
||||
<Row className="flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-sm text-gray-500">{user.website}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
{(user.website || user.twitterHandle || user.discordHandle) && (
|
||||
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-sm text-gray-500">{user.website}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
<Spacer h={5} />
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
|
||||
<Row
|
||||
className={
|
||||
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
|
@ -240,7 +241,6 @@ export function UserPage(props: { user: User }) {
|
|||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Spacer h={5} />
|
||||
<QueryUncontrolledTabs
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 '}
|
||||
|
@ -255,24 +255,31 @@ export function UserPage(props: { user: User }) {
|
|||
title: 'Comments',
|
||||
content: (
|
||||
<Col>
|
||||
<Row className={'mt-2 mb-4 flex-wrap items-center gap-6'}>
|
||||
<UserCommentsList user={user} />
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Trades',
|
||||
content: (
|
||||
<>
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Stats',
|
||||
content: (
|
||||
<Col className="mb-8">
|
||||
<Row className={'mb-8 flex-wrap items-center gap-6'}>
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
<ReferralsButton user={user} />
|
||||
<GroupsButton user={user} />
|
||||
<UserLikesButton user={user} />
|
||||
</Row>
|
||||
<UserCommentsList user={user} />
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
content: (
|
||||
<>
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
|
|
@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
|
|||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
Bet
|
||||
Buy
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import {
|
||||
Comment,
|
||||
ContractComment,
|
||||
GroupComment,
|
||||
PostComment,
|
||||
} from 'common/comment'
|
||||
import {
|
||||
listenForCommentsOnContract,
|
||||
listenForCommentsOnGroup,
|
||||
listenForCommentsOnPost,
|
||||
listenForRecentComments,
|
||||
} from 'web/lib/firebase/comments'
|
||||
|
||||
|
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
|
|||
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 = () => {
|
||||
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
||||
useEffect(() => listenForRecentComments(setRecentComments), [])
|
||||
|
|
|
@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'
|
|||
import { Group } from 'common/group'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
getMemberGroups,
|
||||
GroupMemberDoc,
|
||||
groupMembers,
|
||||
listenForGroup,
|
||||
listenForGroupContractDocs,
|
||||
listenForGroups,
|
||||
listenForMemberGroupIds,
|
||||
listenForMemberGroups,
|
||||
listenForOpenGroups,
|
||||
listGroups,
|
||||
} from 'web/lib/firebase/groups'
|
||||
|
@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array'
|
|||
import { Contract } from 'common/contract'
|
||||
import { uniq } from 'lodash'
|
||||
import { listenForValues } from 'web/lib/firebase/utils'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export const useGroup = (groupId: string | undefined) => {
|
||||
const [group, setGroup] = useState<Group | null | undefined>()
|
||||
|
@ -49,12 +50,10 @@ export const useOpenGroups = () => {
|
|||
}
|
||||
|
||||
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||
useEffect(() => {
|
||||
if (userId)
|
||||
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
|
||||
}, [userId])
|
||||
return memberGroups
|
||||
const result = useQuery(['member-groups', userId ?? ''], () =>
|
||||
getMemberGroups(userId ?? '')
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Note: We cache member group ids in localstorage to speed up the initial load
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
function inIframe() {
|
||||
export function inIframe() {
|
||||
try {
|
||||
return window.self !== window.top
|
||||
} catch (e) {
|
||||
|
|
|
@ -103,6 +103,7 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => {
|
|||
isEnd: state.isComplete && state.pageEnd >= state.docs.length,
|
||||
getPrev: () => dispatch({ type: 'PREV' }),
|
||||
getNext: () => dispatch({ type: 'NEXT' }),
|
||||
allItems: () => state.docs.map((d) => d.data()),
|
||||
getItems: () =>
|
||||
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()),
|
||||
}
|
||||
|
|
22
web/hooks/use-prob-changes.tsx
Normal file
22
web/hooks/use-prob-changes.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import {
|
||||
getProbChangesNegative,
|
||||
getProbChangesPositive,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useProbChanges = (userId: string) => {
|
||||
const { data: positiveChanges } = useFirestoreQueryData(
|
||||
['prob-changes-day-positive', userId],
|
||||
getProbChangesPositive(userId)
|
||||
)
|
||||
const { data: negativeChanges } = useFirestoreQueryData(
|
||||
['prob-changes-day-negative', userId],
|
||||
getProbChangesNegative(userId)
|
||||
)
|
||||
|
||||
if (!positiveChanges || !negativeChanges) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { positiveChanges, negativeChanges }
|
||||
}
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import {
|
||||
listenForTipTxns,
|
||||
listenForTipTxnsOnGroup,
|
||||
listenForTipTxnsOnPost,
|
||||
} from 'web/lib/firebase/txns'
|
||||
|
||||
export type CommentTips = { [userId: string]: number }
|
||||
|
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
|
|||
export function useTipTxns(on: {
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
postId?: string
|
||||
}): CommentTipMap {
|
||||
const [txns, setTxns] = useState<TipTxn[]>([])
|
||||
const { contractId, groupId } = on
|
||||
const { contractId, groupId, postId } = on
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForTipTxns(contractId, setTxns)
|
||||
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
|
||||
}, [contractId, groupId, setTxns])
|
||||
if (postId) return listenForTipTxnsOnPost(postId, setTxns)
|
||||
}, [contractId, groupId, postId, setTxns])
|
||||
|
||||
return useMemo(() => {
|
||||
const byComment = groupBy(txns, 'data.commentId')
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { track } from '@amplitude/analytics-browser'
|
||||
import { useEffect } from 'react'
|
||||
import { inIframe } from './use-is-iframe'
|
||||
|
||||
export const useTracking = (eventName: string, eventProperties?: any) => {
|
||||
export const useTracking = (
|
||||
eventName: string,
|
||||
eventProperties?: any,
|
||||
excludeIframe?: boolean
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (excludeIframe && inIframe()) return
|
||||
track(eventName, eventProperties)
|
||||
}, [])
|
||||
}
|
||||
|
|
|
@ -7,12 +7,22 @@ import {
|
|||
query,
|
||||
setDoc,
|
||||
where,
|
||||
DocumentData,
|
||||
DocumentReference,
|
||||
} from 'firebase/firestore'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
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 { track } from '@amplitude/analytics-browser'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
|
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
|
|||
export async function createCommentOnContract(
|
||||
contractId: string,
|
||||
content: JSONContent,
|
||||
commenter: User,
|
||||
user: User,
|
||||
betId?: string,
|
||||
answerOutcome?: string,
|
||||
replyToCommentId?: string
|
||||
|
@ -32,28 +42,20 @@ export async function createCommentOnContract(
|
|||
const ref = betId
|
||||
? doc(getCommentsCollection(contractId), betId)
|
||||
: doc(getCommentsCollection(contractId))
|
||||
// contract slug and question are set via trigger
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
const onContract = {
|
||||
commentType: 'contract',
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
userName: commenter.name,
|
||||
userUsername: commenter.username,
|
||||
userAvatarUrl: commenter.avatarUrl,
|
||||
betId: betId,
|
||||
answerOutcome: answerOutcome,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
track('comment', {
|
||||
betId,
|
||||
answerOutcome,
|
||||
} as OnContract
|
||||
return await createComment(
|
||||
contractId,
|
||||
commentId: ref.id,
|
||||
betId: betId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
onContract,
|
||||
content,
|
||||
user,
|
||||
ref,
|
||||
replyToCommentId
|
||||
)
|
||||
}
|
||||
export async function createCommentOnGroup(
|
||||
groupId: string,
|
||||
|
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
|
|||
replyToCommentId?: string
|
||||
) {
|
||||
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({
|
||||
id: ref.id,
|
||||
commentType: 'group',
|
||||
groupId,
|
||||
userId: user.id,
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
|
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
|
|||
userUsername: user.username,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
replyToCommentId: replyToCommentId,
|
||||
...extraFields,
|
||||
})
|
||||
track('group message', {
|
||||
|
||||
track(`${extraFields.commentType} message`, {
|
||||
user,
|
||||
commentId: ref.id,
|
||||
groupId,
|
||||
surfaceId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
|
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
|
|||
return collection(db, 'groups', groupId, 'comments')
|
||||
}
|
||||
|
||||
function getCommentsOnPostCollection(postId: string) {
|
||||
return collection(db, 'posts', postId, 'comments')
|
||||
}
|
||||
|
||||
export async function listAllComments(contractId: string) {
|
||||
return await getValues<Comment>(
|
||||
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(
|
||||
contractId: string,
|
||||
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
|
||||
|
||||
// Define "recent" as "<3 days ago" for now
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { partition, sortBy, sum, uniqBy } from 'lodash'
|
||||
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { BinaryContract, Contract } from 'common/contract'
|
||||
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
||||
import { createRNG, shuffle } from 'common/util/random'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
|
@ -104,6 +104,14 @@ export async function listContracts(creatorId: string): Promise<Contract[]> {
|
|||
return snapshot.docs.map((doc) => doc.data())
|
||||
}
|
||||
|
||||
export const tournamentContractsByGroupSlugQuery = (slug: string) =>
|
||||
query(
|
||||
contracts,
|
||||
where('groupSlugs', 'array-contains', slug),
|
||||
where('isResolved', '==', false),
|
||||
orderBy('popularityScore', 'desc')
|
||||
)
|
||||
|
||||
export async function listContractsByGroupSlug(
|
||||
slug: string
|
||||
): Promise<Contract[]> {
|
||||
|
@ -395,3 +403,21 @@ export async function getRecentBetsAndComments(contract: Contract) {
|
|||
recentComments,
|
||||
}
|
||||
}
|
||||
|
||||
export const getProbChangesPositive = (userId: string) =>
|
||||
query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId),
|
||||
where('probChanges.day', '>', 0),
|
||||
orderBy('probChanges.day', 'desc'),
|
||||
limit(10)
|
||||
) as Query<CPMMContract>
|
||||
|
||||
export const getProbChangesNegative = (userId: string) =>
|
||||
query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId),
|
||||
where('probChanges.day', '<', 0),
|
||||
orderBy('probChanges.day', 'asc'),
|
||||
limit(10)
|
||||
) as Query<CPMMContract>
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
updateDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { uniq } from 'lodash'
|
||||
import { uniq, uniqBy } from 'lodash'
|
||||
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||
import {
|
||||
coll,
|
||||
|
@ -21,7 +21,7 @@ import {
|
|||
listenForValues,
|
||||
} from './utils'
|
||||
import { Contract } from 'common/contract'
|
||||
import { updateContract } from 'web/lib/firebase/contracts'
|
||||
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
|
||||
import { db } from 'web/lib/firebase/init'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { getUser } from 'web/lib/firebase/users'
|
||||
|
@ -31,6 +31,9 @@ export const groupMembers = (groupId: string) =>
|
|||
collection(groups, groupId, 'groupMembers')
|
||||
export const groupContracts = (groupId: string) =>
|
||||
collection(groups, groupId, 'groupContracts')
|
||||
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
|
||||
export const memberGroupsQuery = (userId: string) =>
|
||||
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
|
||||
|
||||
export function groupPath(
|
||||
groupSlug: string,
|
||||
|
@ -78,23 +81,25 @@ export function listenForGroupContractDocs(
|
|||
return listenForValues(groupContracts(groupId), setContractDocs)
|
||||
}
|
||||
|
||||
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||
return listenForValues(
|
||||
query(groups, where('anyoneCanJoin', '==', true)),
|
||||
setGroups
|
||||
export async function listGroupContracts(groupId: string) {
|
||||
const contractDocs = await getValues<{
|
||||
contractId: string
|
||||
createdTime: number
|
||||
}>(groupContracts(groupId))
|
||||
const contracts = await Promise.all(
|
||||
contractDocs.map((doc) => getContractFromId(doc.contractId))
|
||||
)
|
||||
return filterDefined(contracts)
|
||||
}
|
||||
|
||||
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||
return listenForValues(openGroupsQuery, setGroups)
|
||||
}
|
||||
|
||||
export function getGroup(groupId: string) {
|
||||
return getValue<Group>(doc(groups, groupId))
|
||||
}
|
||||
|
||||
export function getGroupContracts(groupId: string) {
|
||||
return getValues<{ contractId: string; createdTime: number }>(
|
||||
groupContracts(groupId)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getGroupBySlug(slug: string) {
|
||||
const q = query(groups, where('slug', '==', slug))
|
||||
const docs = (await getDocs(q)).docs
|
||||
|
@ -108,14 +113,20 @@ export function listenForGroup(
|
|||
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(
|
||||
userId: string,
|
||||
setGroupIds: (groupIds: string[]) => void
|
||||
) {
|
||||
const q = query(
|
||||
collectionGroup(db, 'groupMembers'),
|
||||
where('userId', '==', userId)
|
||||
)
|
||||
const q = memberGroupsQuery(userId)
|
||||
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
|
||||
if (snapshot.metadata.fromCache) return
|
||||
|
||||
|
@ -136,6 +147,24 @@ export function listenForMemberGroups(
|
|||
})
|
||||
}
|
||||
|
||||
export async function listAvailableGroups(userId: string) {
|
||||
const [openGroups, memberGroupSnapshot] = await Promise.all([
|
||||
getValues<Group>(openGroupsQuery),
|
||||
getDocs(memberGroupsQuery(userId)),
|
||||
])
|
||||
const memberGroups = filterDefined(
|
||||
await Promise.all(
|
||||
memberGroupSnapshot.docs.map((doc) => {
|
||||
return doc.ref.parent.parent?.id
|
||||
? getGroup(doc.ref.parent.parent?.id)
|
||||
: null
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return uniqBy([...openGroups, ...memberGroups], (g) => g.id)
|
||||
}
|
||||
|
||||
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||
// get group to get the member ids
|
||||
const group = await getGroup(groupId)
|
||||
|
|
|
@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
|
|||
where('data.groupId', '==', groupId)
|
||||
)
|
||||
|
||||
const getTipsOnPostQuery = (postId: string) =>
|
||||
query(
|
||||
txns,
|
||||
where('category', '==', 'TIP'),
|
||||
where('data.postId', '==', postId)
|
||||
)
|
||||
|
||||
export function listenForTipTxns(
|
||||
contractId: string,
|
||||
setTxns: (txns: TipTxn[]) => void
|
||||
|
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
|
|||
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
|
||||
export function useManalinkTxns(userId: string) {
|
||||
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
|
||||
|
|
19
web/lib/icons/challenge-icon.tsx
Normal file
19
web/lib/icons/challenge-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -9,6 +9,9 @@ module.exports = {
|
|||
reactStrictMode: true,
|
||||
optimizeFonts: false,
|
||||
experimental: {
|
||||
images: {
|
||||
allowFutureImage: true,
|
||||
},
|
||||
scrollRestoration: true,
|
||||
externalDir: true,
|
||||
modularizeImports: {
|
||||
|
@ -25,7 +28,12 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
images: {
|
||||
domains: ['lh3.googleusercontent.com', 'i.imgur.com'],
|
||||
domains: [
|
||||
'manifold.markets',
|
||||
'lh3.googleusercontent.com',
|
||||
'i.imgur.com',
|
||||
'firebasestorage.googleapis.com',
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
|
|
|
@ -69,7 +69,7 @@ export async function getStaticPropz(props: {
|
|||
comments: comments.slice(0, 1000),
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
revalidate: 5, // regenerate after five seconds
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,11 +158,15 @@ export function ContractPageContent(
|
|||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
usePrefetch(user?.id)
|
||||
|
||||
useTracking('view market', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
creatorId: contract.creatorId,
|
||||
})
|
||||
useTracking(
|
||||
'view market',
|
||||
{
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
creatorId: contract.creatorId,
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const nonChallengeBets = useMemo(
|
||||
|
|
21
web/pages/api/v0/group/by-id/[id]/markets.ts
Normal file
21
web/pages/api/v0/group/by-id/[id]/markets.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||
import { listGroupContracts } from 'web/lib/firebase/groups'
|
||||
import { toLiteMarket } from 'web/pages/api/v0/_types'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||
const { id } = req.query
|
||||
const contracts = (await listGroupContracts(id as string)).map((contract) =>
|
||||
toLiteMarket(contract)
|
||||
)
|
||||
if (!contracts) {
|
||||
res.status(404).json({ error: 'Group not found' })
|
||||
return
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
return res.status(200).json(contracts)
|
||||
}
|
|
@ -1,14 +1,42 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllGroups } from 'web/lib/firebase/groups'
|
||||
import { listAllGroups, listAvailableGroups } from 'web/lib/firebase/groups'
|
||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||
import { z } from 'zod'
|
||||
import { validate } from 'web/pages/api/v0/_validate'
|
||||
import { ValidationError } from 'web/pages/api/v0/_types'
|
||||
|
||||
type Data = any[]
|
||||
const queryParams = z
|
||||
.object({
|
||||
availableToUserId: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
res: NextApiResponse
|
||||
) {
|
||||
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||
let params: z.infer<typeof queryParams>
|
||||
try {
|
||||
params = validate(queryParams, req.query)
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) {
|
||||
return res.status(400).json(e)
|
||||
}
|
||||
console.error(`Unknown error during validation: ${e}`)
|
||||
return res.status(500).json({ error: 'Unknown error during validation' })
|
||||
}
|
||||
|
||||
const { availableToUserId } = params
|
||||
|
||||
// TODO: should we check if the user is a real user?
|
||||
if (availableToUserId) {
|
||||
const groups = await listAvailableGroups(availableToUserId)
|
||||
res.setHeader('Cache-Control', 'max-age=0')
|
||||
res.status(200).json(groups)
|
||||
return
|
||||
}
|
||||
|
||||
const groups = await listAllGroups()
|
||||
res.setHeader('Cache-Control', 'max-age=0')
|
||||
res.status(200).json(groups)
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { getGroup } from 'web/lib/firebase/groups'
|
||||
import { getGroup, groupPath } from 'web/lib/firebase/groups'
|
||||
import { Group } from 'common/group'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||
|
@ -34,6 +34,8 @@ import { Title } from 'web/components/title'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { ExternalLinkIcon } from '@heroicons/react/outline'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||
|
@ -290,9 +292,9 @@ export function NewContract(props: {
|
|||
}}
|
||||
choicesMap={{
|
||||
'Yes / No': 'BINARY',
|
||||
'Multiple choice': 'MULTIPLE_CHOICE',
|
||||
// 'Multiple choice': 'MULTIPLE_CHOICE',
|
||||
'Free response': 'FREE_RESPONSE',
|
||||
Numeric: 'PSEUDO_NUMERIC',
|
||||
// Numeric: 'PSEUDO_NUMERIC',
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
className={'col-span-4'}
|
||||
|
@ -406,13 +408,19 @@ export function NewContract(props: {
|
|||
|
||||
<Spacer h={6} />
|
||||
|
||||
<GroupSelector
|
||||
selectedGroup={selectedGroup}
|
||||
setSelectedGroup={setSelectedGroup}
|
||||
creator={creator}
|
||||
options={{ showSelector: showGroupSelector, showLabel: true }}
|
||||
/>
|
||||
|
||||
<Row className={'items-end gap-x-2'}>
|
||||
<GroupSelector
|
||||
selectedGroup={selectedGroup}
|
||||
setSelectedGroup={setSelectedGroup}
|
||||
creator={creator}
|
||||
options={{ showSelector: showGroupSelector, showLabel: true }}
|
||||
/>
|
||||
{showGroupSelector && selectedGroup && (
|
||||
<SiteLink href={groupPath(selectedGroup.slug)}>
|
||||
<ExternalLinkIcon className=" ml-1 mb-3 h-5 w-5 text-gray-500" />
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
<Spacer h={6} />
|
||||
|
||||
<div className="form-control mb-1 items-start">
|
||||
|
@ -483,17 +491,17 @@ export function NewContract(props: {
|
|||
{formatMoney(ante)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label-text text-primary pl-1">
|
||||
FREE{' '}
|
||||
<span className="label-text pl-1 text-gray-500">
|
||||
(You have{' '}
|
||||
{FREE_MARKETS_PER_USER_MAX -
|
||||
(creator?.freeMarketsCreated ?? 0)}{' '}
|
||||
free markets left)
|
||||
</span>
|
||||
<Row>
|
||||
<div className="label-text text-neutral pl-1 line-through">
|
||||
{formatMoney(ante)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="label-text text-primary pl-1">FREE </div>
|
||||
<div className="label-text pl-1 text-gray-500">
|
||||
(You have{' '}
|
||||
{FREE_MARKETS_PER_USER_MAX - (creator?.freeMarketsCreated ?? 0)}{' '}
|
||||
free markets left)
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{ante > balance && !deservesFreeMarket && (
|
||||
|
|
|
@ -21,6 +21,7 @@ import { SiteLink } from 'web/components/site-link'
|
|||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { listAllBets } from 'web/lib/firebase/bets'
|
||||
import {
|
||||
contractPath,
|
||||
|
@ -82,6 +83,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
const { contract, bets } = props
|
||||
const { question, outcomeType } = contract
|
||||
|
||||
useTracking('view market embed', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
creatorId: contract.creatorId,
|
||||
})
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
|
|
60
web/pages/experimental/home/edit.tsx
Normal file
60
web/pages/experimental/home/edit.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
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 ?? { visible: [], hidden: [] }
|
||||
)
|
||||
|
||||
const updateHomeSections = (newHomeSections: {
|
||||
visible: string[]
|
||||
hidden: string[]
|
||||
}) => {
|
||||
if (!user) return
|
||||
updateUser(user.id, { homeSections: newHomeSections })
|
||||
setHomeSections(newHomeSections)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-12">
|
||||
<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>
|
||||
)
|
||||
}
|
|
@ -1,39 +1,36 @@
|
|||
import React, { useState } from 'react'
|
||||
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 { Col } from 'web/components/layout/col'
|
||||
import { ContractSearch, SORTS } from 'web/components/contract-search'
|
||||
import { User } from 'common/user'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
import { Group } from 'common/group'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { GroupLinkItem } from '../../groups'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { DoubleCarousel } from '../../../components/double-carousel'
|
||||
import clsx from 'clsx'
|
||||
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 { Row } from 'web/components/layout/row'
|
||||
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'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
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
|
||||
const Home = () => {
|
||||
const user = useUser()
|
||||
|
||||
useTracking('view home')
|
||||
|
||||
|
@ -41,71 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
|
||||
const [homeSections, setHomeSections] = useState(
|
||||
const [homeSections] = useState(
|
||||
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 (
|
||||
<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'}>
|
||||
<Title text={isEditing ? 'Edit your home page' : 'Home'} />
|
||||
<Title className="!mb-0" text="Home" />
|
||||
|
||||
<EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} />
|
||||
<EditButton />
|
||||
</Row>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ArrangeHome
|
||||
user={user}
|
||||
homeSections={homeSections}
|
||||
setHomeSections={updateHomeSections}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
visibleItems.map((item) => {
|
||||
const { id } = item
|
||||
if (id === 'your-bets') {
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={'Your bets'}
|
||||
sort={'newest'}
|
||||
user={user}
|
||||
yourBets
|
||||
/>
|
||||
)
|
||||
}
|
||||
const sort = SORTS.find((sort) => sort.value === id)
|
||||
if (sort)
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={sort.label}
|
||||
sort={sort.value}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
<DailyProfitAndBalance userId={user?.id} />
|
||||
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group)
|
||||
return <GroupSection key={id} group={group} user={user} />
|
||||
<div className="text-xl text-gray-800">Daily movers</div>
|
||||
<ProbChangeTable userId={user?.id} />
|
||||
|
||||
return null
|
||||
})
|
||||
)}
|
||||
{visibleItems.map((item) => {
|
||||
const { id } = item
|
||||
if (id === 'your-bets') {
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={'Your trades'}
|
||||
sort={'newest'}
|
||||
user={user}
|
||||
yourBets
|
||||
/>
|
||||
)
|
||||
}
|
||||
const sort = SORTS.find((sort) => sort.value === id)
|
||||
if (sort)
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={sort.label}
|
||||
sort={sort.value}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group) return <GroupSection key={id} group={group} user={user} />
|
||||
|
||||
return null
|
||||
})}
|
||||
</Col>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -123,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
|
||||
function SearchSection(props: {
|
||||
label: string
|
||||
user: User | null
|
||||
user: User | null | undefined
|
||||
sort: Sort
|
||||
yourBets?: boolean
|
||||
}) {
|
||||
|
@ -133,88 +113,91 @@ function SearchSection(props: {
|
|||
return (
|
||||
<Col>
|
||||
<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>
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={sort}
|
||||
additionalFilter={yourBets ? { yourBets: true } : undefined}
|
||||
additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
|
||||
noControls
|
||||
// persistPrefix={`experimental-home-${sort}`}
|
||||
renderContracts={(contracts, loadMore) =>
|
||||
contracts ? (
|
||||
<DoubleCarousel
|
||||
contracts={contracts}
|
||||
seeMoreUrl={href}
|
||||
showTime={
|
||||
sort === 'close-date' || sort === 'resolve-date'
|
||||
? sort
|
||||
: undefined
|
||||
}
|
||||
loadMore={loadMore}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
}
|
||||
maxResults={6}
|
||||
persistPrefix={`experimental-home-${sort}`}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSection(props: { group: Group; user: User | null }) {
|
||||
function GroupSection(props: { group: Group; user: User | null | undefined }) {
|
||||
const { group, user } = props
|
||||
|
||||
return (
|
||||
<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
|
||||
user={user}
|
||||
defaultSort={'score'}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
noControls
|
||||
// persistPrefix={`experimental-home-${group.slug}`}
|
||||
renderContracts={(contracts, loadMore) =>
|
||||
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 />
|
||||
)
|
||||
}
|
||||
maxResults={6}
|
||||
persistPrefix={`experimental-home-${group.slug}`}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function EditDoneButton(props: {
|
||||
isEditing: boolean
|
||||
setIsEditing: (isEditing: boolean) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { isEditing, setIsEditing, className } = props
|
||||
function EditButton(props: { className?: string }) {
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
color={isEditing ? 'blue' : 'gray-white'}
|
||||
className={clsx(className, 'flex')}
|
||||
onClick={() => {
|
||||
setIsEditing(!isEditing)
|
||||
}}
|
||||
>
|
||||
{!isEditing && (
|
||||
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
|
||||
)}
|
||||
{isEditing ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ import { Post } from 'common/post'
|
|||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { usePost } from 'web/hooks/use-post'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -226,6 +227,7 @@ export default function GroupPage(props: {
|
|||
defaultSort={'newest'}
|
||||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
/>
|
||||
)
|
||||
|
||||
|
@ -659,22 +661,25 @@ function JoinGroupButton(props: {
|
|||
user: User | null | undefined
|
||||
}) {
|
||||
const { group, user } = props
|
||||
function addUserToGroup() {
|
||||
if (user) {
|
||||
toast.promise(joinGroup(group, user.id), {
|
||||
loading: 'Joining group...',
|
||||
success: 'Joined group!',
|
||||
error: "Couldn't join group, try again?",
|
||||
})
|
||||
}
|
||||
|
||||
const follow = async () => {
|
||||
track('join group')
|
||||
const userId = user ? user.id : (await firebaseLogin()).user.uid
|
||||
|
||||
toast.promise(joinGroup(group, userId), {
|
||||
loading: 'Following group...',
|
||||
success: 'Followed',
|
||||
error: "Couldn't follow group, try again?",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={user ? addUserToGroup : firebaseLogin}
|
||||
onClick={follow}
|
||||
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
|
||||
>
|
||||
{user ? 'Join group' : 'Login to join group'}
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -65,20 +65,9 @@ export default function Groups(props: {
|
|||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// List groups with the highest question count, then highest member count
|
||||
// TODO use find-active-contracts to sort by?
|
||||
const matches = sortBy(groups, []).filter((g) =>
|
||||
searchInAny(
|
||||
query,
|
||||
g.name,
|
||||
g.about || '',
|
||||
creatorsDict[g.creatorId].username
|
||||
)
|
||||
)
|
||||
|
||||
const matchesOrderedByRecentActivity = sortBy(groups, [
|
||||
(group) =>
|
||||
-1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime),
|
||||
const matchesOrderedByMostContractAndMembers = sortBy(groups, [
|
||||
(group) => -1 * group.totalContracts,
|
||||
(group) => -1 * group.totalMembers,
|
||||
]).filter((g) =>
|
||||
searchInAny(
|
||||
query,
|
||||
|
@ -120,13 +109,14 @@ export default function Groups(props: {
|
|||
<Col>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search your groups"
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{matchesOrderedByRecentActivity
|
||||
{matchesOrderedByMostContractAndMembers
|
||||
.filter((match) =>
|
||||
memberGroupIds.includes(match.id)
|
||||
)
|
||||
|
@ -153,11 +143,12 @@ export default function Groups(props: {
|
|||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search groups"
|
||||
value={query}
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{matches.map((group) => (
|
||||
{matchesOrderedByMostContractAndMembers.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
|
|
|
@ -135,21 +135,20 @@ export default function Leaderboards(_props: {
|
|||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
title: 'All Time',
|
||||
content: LeaderboardWithPeriod('allTime'),
|
||||
title: 'Daily',
|
||||
content: LeaderboardWithPeriod('daily'),
|
||||
},
|
||||
// TODO: Enable this near the end of July!
|
||||
// {
|
||||
// title: 'Monthly',
|
||||
// content: LeaderboardWithPeriod('monthly'),
|
||||
// },
|
||||
{
|
||||
title: 'Weekly',
|
||||
content: LeaderboardWithPeriod('weekly'),
|
||||
},
|
||||
{
|
||||
title: 'Daily',
|
||||
content: LeaderboardWithPeriod('daily'),
|
||||
title: 'Monthly',
|
||||
content: LeaderboardWithPeriod('monthly'),
|
||||
},
|
||||
{
|
||||
title: 'All Time',
|
||||
content: LeaderboardWithPeriod('allTime'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} unique traders on`
|
||||
} new traders on`
|
||||
: 'bonus on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
|
@ -508,7 +508,7 @@ function IncomeNotificationItem(props: {
|
|||
{(isTip || isUniqueBettorBonus) && (
|
||||
<MultiUserTransactionLink
|
||||
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'}>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
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 { Title } from 'web/components/title'
|
||||
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 { ShareIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon, ShareIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useState } from 'react'
|
||||
|
@ -16,17 +16,27 @@ import { Col } from 'web/components/layout/col'
|
|||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import Custom404 from 'web/pages/404'
|
||||
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[] } }) {
|
||||
const { slugs } = props.params
|
||||
|
||||
const post = await getPostBySlug(slugs[0])
|
||||
const creator = post ? await getUser(post.creatorId) : null
|
||||
const comments = post && (await listAllCommentsOnPost(post.id))
|
||||
|
||||
return {
|
||||
props: {
|
||||
post: post,
|
||||
creator: creator,
|
||||
comments: comments,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -37,28 +47,38 @@ export async function getStaticPaths() {
|
|||
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 { 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 />
|
||||
}
|
||||
|
||||
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-3xl ">
|
||||
<Spacer h={1} />
|
||||
<Title className="!mt-0" text={props.post.title} />
|
||||
<Title className="!mt-0" text={post.title} />
|
||||
<Row>
|
||||
<Col className="flex-1">
|
||||
<div className={'inline-flex'}>
|
||||
<div className="mr-1 text-gray-500">Created by</div>
|
||||
<UserLink
|
||||
className="text-neutral"
|
||||
name={props.creator.name}
|
||||
username={props.creator.username}
|
||||
name={creator.name}
|
||||
username={creator.username}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
|
@ -88,10 +108,121 @@ export default function PostPage(props: { post: Post; creator: User }) {
|
|||
<Spacer h={2} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<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>
|
||||
|
||||
<Spacer h={2} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<PostCommentsActivity
|
||||
post={post}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={creator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
import { ClockIcon } from '@heroicons/react/outline'
|
||||
import { UsersIcon } from '@heroicons/react/solid'
|
||||
import {
|
||||
BinaryContract,
|
||||
Contract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import { Group } from 'common/group'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { keyBy, mapValues, sortBy } from 'lodash'
|
||||
import { zip } from 'lodash'
|
||||
import Image, { ImageProps, StaticImageData } from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col'
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||
import { tournamentContractsByGroupSlugQuery } from 'web/lib/firebase/contracts'
|
||||
import { getGroup, groupPath } from 'web/lib/firebase/groups'
|
||||
import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png'
|
||||
import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png'
|
||||
import mpox_pic from './_cspi/Monkeypox_Cases.png'
|
||||
import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { Carousel } from 'web/components/carousel'
|
||||
import { usePagination } from 'web/hooks/use-pagination'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(customParseFormat)
|
||||
const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles')
|
||||
const toDate = (d: string) =>
|
||||
dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles').valueOf()
|
||||
|
||||
type MarketImage = {
|
||||
marketUrl: string
|
||||
image: StaticImageData
|
||||
}
|
||||
|
||||
type Tourney = {
|
||||
title: string
|
||||
url?: string
|
||||
blurb: string // actual description in the click-through
|
||||
award?: string
|
||||
endTime?: Dayjs
|
||||
endTime?: number
|
||||
groupId: string
|
||||
}
|
||||
|
||||
|
@ -50,7 +50,7 @@ const Salem = {
|
|||
url: 'https://salemcenter.manifold.markets/',
|
||||
award: '$25,000',
|
||||
endTime: toDate('Jul 31, 2023'),
|
||||
markets: [],
|
||||
contractIds: [],
|
||||
images: [
|
||||
{
|
||||
marketUrl:
|
||||
|
@ -107,33 +107,27 @@ const tourneys: Tourney[] = [
|
|||
// },
|
||||
]
|
||||
|
||||
export async function getStaticProps() {
|
||||
const groupIds = tourneys
|
||||
.map((data) => data.groupId)
|
||||
.filter((id) => id != undefined) as string[]
|
||||
const groups = (await Promise.all(groupIds.map(getGroup)))
|
||||
// Then remove undefined groups
|
||||
.filter(Boolean) as Group[]
|
||||
|
||||
const contracts = await Promise.all(
|
||||
groups.map((g) => listContractsByGroupSlug(g?.slug ?? ''))
|
||||
)
|
||||
|
||||
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
|
||||
|
||||
const groupMap = keyBy(groups, 'id')
|
||||
const numPeople = mapValues(groupMap, (g) => g?.totalMembers)
|
||||
const slugs = mapValues(groupMap, 'slug')
|
||||
|
||||
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
|
||||
type SectionInfo = {
|
||||
tourney: Tourney
|
||||
slug: string
|
||||
numPeople: number
|
||||
}
|
||||
|
||||
export default function TournamentPage(props: {
|
||||
markets: { [groupId: string]: Contract[] }
|
||||
numPeople: { [groupId: string]: number }
|
||||
slugs: { [groupId: string]: string }
|
||||
}) {
|
||||
const { markets = {}, numPeople = {}, slugs = {} } = props
|
||||
export async function getStaticProps() {
|
||||
const groupIds = tourneys.map((data) => data.groupId)
|
||||
const groups = await Promise.all(groupIds.map(getGroup))
|
||||
const sections = zip(tourneys, groups)
|
||||
.filter(([_tourney, group]) => group != null)
|
||||
.map(([tourney, group]) => ({
|
||||
tourney,
|
||||
slug: group!.slug, // eslint-disable-line
|
||||
numPeople: group!.totalMembers, // eslint-disable-line
|
||||
}))
|
||||
return { props: { sections } }
|
||||
}
|
||||
|
||||
export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
||||
const { sections } = props
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
@ -141,96 +135,114 @@ export default function TournamentPage(props: {
|
|||
title="Tournaments"
|
||||
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
||||
/>
|
||||
<Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]">
|
||||
{tourneys.map(({ groupId, ...data }) => (
|
||||
<Section
|
||||
key={groupId}
|
||||
{...data}
|
||||
url={groupPath(slugs[groupId])}
|
||||
ppl={numPeople[groupId] ?? 0}
|
||||
markets={markets[groupId] ?? []}
|
||||
/>
|
||||
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
|
||||
{sections.map(({ tourney, slug, numPeople }) => (
|
||||
<div key={slug}>
|
||||
<SectionHeader
|
||||
url={groupPath(slug)}
|
||||
title={tourney.title}
|
||||
ppl={numPeople}
|
||||
award={tourney.award}
|
||||
endTime={tourney.endTime}
|
||||
/>
|
||||
<span>{tourney.blurb}</span>
|
||||
<MarketCarousel slug={slug} />
|
||||
</div>
|
||||
))}
|
||||
<Section {...Salem} />
|
||||
<div>
|
||||
<SectionHeader
|
||||
url={Salem.url}
|
||||
title={Salem.title}
|
||||
award={Salem.award}
|
||||
endTime={Salem.endTime}
|
||||
/>
|
||||
<span>{Salem.blurb}</span>
|
||||
<ImageCarousel url={Salem.url} images={Salem.images} />
|
||||
</div>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function Section(props: {
|
||||
title: string
|
||||
const SectionHeader = (props: {
|
||||
url: string
|
||||
blurb: string
|
||||
award?: string
|
||||
title: string
|
||||
ppl?: number
|
||||
endTime?: Dayjs
|
||||
markets: Contract[]
|
||||
images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi
|
||||
}) {
|
||||
const { title, url, blurb, award, ppl, endTime, images } = props
|
||||
// Sort markets by probability, highest % first
|
||||
const markets = sortBy(props.markets, (c) =>
|
||||
getProbability(c as BinaryContract | PseudoNumericContract)
|
||||
)
|
||||
.reverse()
|
||||
.filter((c) => !c.isResolved)
|
||||
|
||||
award?: string
|
||||
endTime?: number
|
||||
}) => {
|
||||
const { url, title, ppl, award, endTime } = props
|
||||
return (
|
||||
<div>
|
||||
<Link href={url}>
|
||||
<a className="group mb-3 flex flex-wrap justify-between">
|
||||
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
<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>}
|
||||
{!!ppl && (
|
||||
<Link href={url}>
|
||||
<a className="group mb-3 flex flex-wrap justify-between">
|
||||
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
<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>}
|
||||
{!!ppl && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersIcon className="h-4" />
|
||||
{ppl}
|
||||
</span>
|
||||
)}
|
||||
{endTime && (
|
||||
<DateTimeTooltip time={endTime} text="Ends">
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersIcon className="h-4" />
|
||||
{ppl}
|
||||
<ClockIcon className="h-4" />
|
||||
{dayjs(endTime).format('MMM D')}
|
||||
</span>
|
||||
)}
|
||||
{endTime && (
|
||||
<DateTimeTooltip time={endTime.valueOf()} text="Ends">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="h-4" />
|
||||
{endTime.format('MMM D')}
|
||||
</span>
|
||||
</DateTimeTooltip>
|
||||
)}
|
||||
</Row>
|
||||
</DateTimeTooltip>
|
||||
)}
|
||||
</Row>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageCarousel = (props: { images: MarketImage[]; url: string }) => {
|
||||
const { images, url } = props
|
||||
return (
|
||||
<Carousel className="-mx-4 mt-4 sm:-mx-10">
|
||||
<div className="shrink-0 sm:w-6" />
|
||||
{images.map(({ marketUrl, image }) => (
|
||||
<a key={marketUrl} href={marketUrl} className="hover:brightness-95">
|
||||
<NaturalImage src={image} />
|
||||
</a>
|
||||
</Link>
|
||||
<span>{blurb}</span>
|
||||
<Carousel className="-mx-4 mt-2 sm:-mx-10">
|
||||
<div className="shrink-0 sm:w-6" />
|
||||
{markets.length ? (
|
||||
markets.map((m) => (
|
||||
<ContractCard
|
||||
contract={m}
|
||||
hideGroupLink
|
||||
className="mb-2 max-h-[200px] w-96 shrink-0"
|
||||
questionClass="line-clamp-3"
|
||||
trackingPostfix=" tournament"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{images?.map(({ marketUrl, image }) => (
|
||||
<a href={marketUrl} className="hover:brightness-95">
|
||||
<NaturalImage src={image} />
|
||||
</a>
|
||||
))}
|
||||
<SiteLink
|
||||
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
|
||||
href={url}
|
||||
>
|
||||
See more
|
||||
</SiteLink>
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</div>
|
||||
))}
|
||||
<SiteLink
|
||||
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
|
||||
href={url}
|
||||
>
|
||||
See more
|
||||
</SiteLink>
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
const MarketCarousel = (props: { slug: string }) => {
|
||||
const { slug } = props
|
||||
const q = tournamentContractsByGroupSlugQuery(slug)
|
||||
const { allItems, getNext } = usePagination({ q, pageSize: 6 })
|
||||
const items = allItems()
|
||||
|
||||
// todo: would be nice to have indicator somewhere when it loads next page
|
||||
return items.length === 0 ? (
|
||||
<LoadingIndicator className="mt-10" />
|
||||
) : (
|
||||
<Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}>
|
||||
<div className="shrink-0 sm:w-6" />
|
||||
{items.map((m) => (
|
||||
<ContractCard
|
||||
key={m.id}
|
||||
contract={m}
|
||||
hideGroupLink
|
||||
className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
|
||||
questionClass="line-clamp-3"
|
||||
trackingPostfix=" tournament"
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
172
web/posts/post-comments.tsx
Normal file
172
web/posts/post-comments.tsx
Normal 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>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user