Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
9b6fda0c31
|
@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people.
|
||||||
Reduce the number of easily preventable deaths worldwide.
|
Reduce the number of easily preventable deaths worldwide.
|
||||||
Work towards sustainable, systemic change.`,
|
Work towards sustainable, systemic change.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'YIMBY Law',
|
||||||
|
website: 'https://www.yimbylaw.org/',
|
||||||
|
photo: 'https://i.imgur.com/zlzp21Z.png',
|
||||||
|
preview:
|
||||||
|
'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.',
|
||||||
|
description: `
|
||||||
|
YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply.
|
||||||
|
|
||||||
|
If you would like to support our work, you can do so by getting involved or by donating.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CaRLA',
|
||||||
|
website: 'https://carlaef.org/',
|
||||||
|
photo: 'https://i.imgur.com/IsNVTOY.png',
|
||||||
|
preview:
|
||||||
|
'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.',
|
||||||
|
description: `
|
||||||
|
The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.
|
||||||
|
|
||||||
|
CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage.
|
||||||
|
|
||||||
|
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
||||||
|
},
|
||||||
].map((charity) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
export type AnyCommentType = OnContract | OnGroup
|
||||||
|
|
||||||
// Currently, comments are created after the bet, not atomically with the bet.
|
// Currently, comments are created after the bet, not atomically with the bet.
|
||||||
// They're uniquely identified by the pair contractId/betId.
|
// They're uniquely identified by the pair contractId/betId.
|
||||||
export type Comment = {
|
export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||||
id: string
|
id: string
|
||||||
contractId?: string
|
|
||||||
groupId?: string
|
|
||||||
betId?: string
|
|
||||||
answerOutcome?: string
|
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
|
@ -20,6 +18,21 @@ export type Comment = {
|
||||||
userName: string
|
userName: string
|
||||||
userUsername: string
|
userUsername: string
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
contractSlug?: string
|
} & T
|
||||||
contractQuestion?: string
|
|
||||||
|
type OnContract = {
|
||||||
|
commentType: 'contract'
|
||||||
|
contractId: string
|
||||||
|
contractSlug: string
|
||||||
|
contractQuestion: string
|
||||||
|
answerOutcome?: string
|
||||||
|
betId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OnGroup = {
|
||||||
|
commentType: 'group'
|
||||||
|
groupId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContractComment = Comment<OnContract>
|
||||||
|
export type GroupComment = Comment<OnGroup>
|
||||||
|
|
151
common/contract-details.ts
Normal file
151
common/contract-details.ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import { Challenge } from './challenge'
|
||||||
|
import { BinaryContract, Contract } from './contract'
|
||||||
|
import { getFormattedMappedValue } from './pseudo-numeric'
|
||||||
|
import { getProbability } from './calculate'
|
||||||
|
import { richTextToString } from './util/parse'
|
||||||
|
import { getCpmmProbability } from './calculate-cpmm'
|
||||||
|
import { getDpmProbability } from './calculate-dpm'
|
||||||
|
import { formatMoney, formatPercent } from './util/format'
|
||||||
|
|
||||||
|
export function contractMetrics(contract: Contract) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const dayjs = require('dayjs')
|
||||||
|
const { createdTime, resolutionTime, isResolved } = contract
|
||||||
|
|
||||||
|
const createdDate = dayjs(createdTime).format('MMM D')
|
||||||
|
|
||||||
|
const resolvedDate = isResolved
|
||||||
|
? dayjs(resolutionTime).format('MMM D')
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const volumeLabel = `${formatMoney(contract.volume)} bet`
|
||||||
|
|
||||||
|
return { volumeLabel, createdDate, resolvedDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String version of the above, to send to the OpenGraph image generator
|
||||||
|
export function contractTextDetails(contract: Contract) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const dayjs = require('dayjs')
|
||||||
|
const { closeTime, tags } = contract
|
||||||
|
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||||
|
|
||||||
|
const hashtags = tags.map((tag) => `#${tag}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||||
|
(closeTime
|
||||||
|
? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs(
|
||||||
|
closeTime
|
||||||
|
).format('MMM D, h:mma')}`
|
||||||
|
: '') +
|
||||||
|
` • ${volumeLabel}` +
|
||||||
|
(hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBinaryProb(contract: BinaryContract) {
|
||||||
|
const { pool, resolutionProbability, mechanism } = contract
|
||||||
|
|
||||||
|
return (
|
||||||
|
resolutionProbability ??
|
||||||
|
(mechanism === 'cpmm-1'
|
||||||
|
? getCpmmProbability(pool, contract.p)
|
||||||
|
: getDpmProbability(contract.totalShares))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOpenGraphProps = (contract: Contract) => {
|
||||||
|
const {
|
||||||
|
resolution,
|
||||||
|
question,
|
||||||
|
creatorName,
|
||||||
|
creatorUsername,
|
||||||
|
outcomeType,
|
||||||
|
creatorAvatarUrl,
|
||||||
|
description: desc,
|
||||||
|
} = contract
|
||||||
|
const probPercent =
|
||||||
|
outcomeType === 'BINARY'
|
||||||
|
? formatPercent(getBinaryProb(contract))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const numericValue =
|
||||||
|
outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
? getFormattedMappedValue(contract)(getProbability(contract))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||||
|
|
||||||
|
const description = resolution
|
||||||
|
? `Resolved ${resolution}. ${stringDesc}`
|
||||||
|
: probPercent
|
||||||
|
? `${probPercent} chance. ${stringDesc}`
|
||||||
|
: stringDesc
|
||||||
|
|
||||||
|
return {
|
||||||
|
question,
|
||||||
|
probability: probPercent,
|
||||||
|
metadata: contractTextDetails(contract),
|
||||||
|
creatorName,
|
||||||
|
creatorUsername,
|
||||||
|
creatorAvatarUrl,
|
||||||
|
description,
|
||||||
|
numericValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OgCardProps = {
|
||||||
|
question: string
|
||||||
|
probability?: string
|
||||||
|
metadata: string
|
||||||
|
creatorName: string
|
||||||
|
creatorUsername: string
|
||||||
|
creatorAvatarUrl?: string
|
||||||
|
numericValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||||
|
const {
|
||||||
|
creatorAmount,
|
||||||
|
acceptances,
|
||||||
|
acceptorAmount,
|
||||||
|
creatorOutcome,
|
||||||
|
acceptorOutcome,
|
||||||
|
} = challenge || {}
|
||||||
|
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||||
|
|
||||||
|
const probabilityParam =
|
||||||
|
props.probability === undefined
|
||||||
|
? ''
|
||||||
|
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||||
|
|
||||||
|
const numericValueParam =
|
||||||
|
props.numericValue === undefined
|
||||||
|
? ''
|
||||||
|
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
||||||
|
|
||||||
|
const creatorAvatarUrlParam =
|
||||||
|
props.creatorAvatarUrl === undefined
|
||||||
|
? ''
|
||||||
|
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
||||||
|
|
||||||
|
const challengeUrlParams = challenge
|
||||||
|
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||||
|
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
|
||||||
|
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// URL encode each of the props, then add them as query params
|
||||||
|
return (
|
||||||
|
`https://manifold-og-image.vercel.app/m.png` +
|
||||||
|
`?question=${encodeURIComponent(props.question)}` +
|
||||||
|
probabilityParam +
|
||||||
|
numericValueParam +
|
||||||
|
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||||
|
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||||
|
creatorAvatarUrlParam +
|
||||||
|
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||||
|
challengeUrlParams
|
||||||
|
)
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
description: string | JSONContent // More info about what the contract is about
|
description: string | JSONContent // More info about what the contract is about
|
||||||
tags: string[]
|
tags: string[]
|
||||||
lowercaseTags: string[]
|
lowercaseTags: string[]
|
||||||
visibility: 'public' | 'unlisted'
|
visibility: visibility
|
||||||
|
|
||||||
createdTime: number // Milliseconds since epoch
|
createdTime: number // Milliseconds since epoch
|
||||||
lastUpdatedTime?: number // Updated on new bet or comment
|
lastUpdatedTime?: number // Updated on new bet or comment
|
||||||
|
@ -143,3 +143,6 @@ export const MAX_DESCRIPTION_LENGTH = 16000
|
||||||
export const MAX_TAG_LENGTH = 60
|
export const MAX_TAG_LENGTH = 60
|
||||||
|
|
||||||
export const CPMM_MIN_POOL_QTY = 0.01
|
export const CPMM_MIN_POOL_QTY = 0.01
|
||||||
|
|
||||||
|
export type visibility = 'public' | 'unlisted'
|
||||||
|
export const VISIBILITIES = ['public', 'unlisted'] as const
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Numeric,
|
Numeric,
|
||||||
outcomeType,
|
outcomeType,
|
||||||
PseudoNumeric,
|
PseudoNumeric,
|
||||||
|
visibility,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { parseTags, richTextToString } from './util/parse'
|
import { parseTags, richTextToString } from './util/parse'
|
||||||
|
@ -34,7 +35,8 @@ export function getNewContract(
|
||||||
isLogScale: boolean,
|
isLogScale: boolean,
|
||||||
|
|
||||||
// for multiple choice
|
// for multiple choice
|
||||||
answers: string[]
|
answers: string[],
|
||||||
|
visibility: visibility
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
const tags = parseTags(
|
||||||
[
|
[
|
||||||
|
@ -70,7 +72,7 @@ export function getNewContract(
|
||||||
description,
|
description,
|
||||||
tags,
|
tags,
|
||||||
lowercaseTags,
|
lowercaseTags,
|
||||||
visibility: 'public',
|
visibility,
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
closeTime,
|
closeTime,
|
||||||
|
|
|
@ -38,6 +38,7 @@ export type notification_source_types =
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'bonus'
|
| 'bonus'
|
||||||
| 'challenge'
|
| 'challenge'
|
||||||
|
| 'betting_streak_bonus'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -66,3 +67,4 @@ export type notification_reason_types =
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
|
| 'betting_streak_incremented'
|
||||||
|
|
|
@ -4,3 +4,6 @@ export const NUMERIC_FIXED_VAR = 0.005
|
||||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||||
|
export const BETTING_STREAK_BONUS_AMOUNT = 5
|
||||||
|
export const BETTING_STREAK_BONUS_MAX = 100
|
||||||
|
export const BETTING_STREAK_RESET_HOUR = 0
|
||||||
|
|
|
@ -1,187 +0,0 @@
|
||||||
import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
|
|
||||||
import { Bet } from './bet'
|
|
||||||
import { Contract } from './contract'
|
|
||||||
import { ClickEvent } from './tracking'
|
|
||||||
import { filterDefined } from './util/array'
|
|
||||||
import { addObjects } from './util/object'
|
|
||||||
|
|
||||||
export const MAX_FEED_CONTRACTS = 75
|
|
||||||
|
|
||||||
export const getRecommendedContracts = (
|
|
||||||
contractsById: { [contractId: string]: Contract },
|
|
||||||
yourBetOnContractIds: string[]
|
|
||||||
) => {
|
|
||||||
const contracts = Object.values(contractsById)
|
|
||||||
const yourContracts = filterDefined(
|
|
||||||
yourBetOnContractIds.map((contractId) => contractsById[contractId])
|
|
||||||
)
|
|
||||||
|
|
||||||
const yourContractIds = new Set(yourContracts.map((c) => c.id))
|
|
||||||
const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id))
|
|
||||||
|
|
||||||
const yourWordFrequency = contractsToWordFrequency(yourContracts)
|
|
||||||
const otherWordFrequency = contractsToWordFrequency(notYourContracts)
|
|
||||||
const words = union(
|
|
||||||
Object.keys(yourWordFrequency),
|
|
||||||
Object.keys(otherWordFrequency)
|
|
||||||
)
|
|
||||||
|
|
||||||
const yourWeightedFrequency = Object.fromEntries(
|
|
||||||
words.map((word) => {
|
|
||||||
const [yourFreq, otherFreq] = [
|
|
||||||
yourWordFrequency[word] ?? 0,
|
|
||||||
otherWordFrequency[word] ?? 0,
|
|
||||||
]
|
|
||||||
|
|
||||||
const score = yourFreq / (yourFreq + otherFreq + 0.0001)
|
|
||||||
|
|
||||||
return [word, score]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// console.log(
|
|
||||||
// 'your weighted frequency',
|
|
||||||
// _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq)
|
|
||||||
// )
|
|
||||||
|
|
||||||
const scoredContracts = contracts.map((contract) => {
|
|
||||||
const wordFrequency = contractToWordFrequency(contract)
|
|
||||||
|
|
||||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
|
||||||
const wordFreq = wordFrequency[word] ?? 0
|
|
||||||
const weight = yourWeightedFrequency[word] ?? 0
|
|
||||||
return wordFreq * weight
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
contract,
|
|
||||||
score,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return sortBy(scoredContracts, (scored) => -scored.score).map(
|
|
||||||
(scored) => scored.contract
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractToText = (contract: Contract) => {
|
|
||||||
const { description, question, tags, creatorUsername } = contract
|
|
||||||
return `${creatorUsername} ${question} ${tags.join(' ')} ${description}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_CHARS_IN_WORD = 100
|
|
||||||
|
|
||||||
const getWordsCount = (text: string) => {
|
|
||||||
const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase()
|
|
||||||
const words = normalizedText
|
|
||||||
.split(' ')
|
|
||||||
.filter((word) => word)
|
|
||||||
.filter((word) => word.length <= MAX_CHARS_IN_WORD)
|
|
||||||
|
|
||||||
const counts: { [word: string]: number } = {}
|
|
||||||
for (const word of words) {
|
|
||||||
if (counts[word]) counts[word]++
|
|
||||||
else counts[word] = 1
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|
||||||
const toFrequency = (counts: { [word: string]: number }) => {
|
|
||||||
const total = sum(Object.values(counts))
|
|
||||||
return mapValues(counts, (count) => count / total)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractToWordFrequency = (contract: Contract) =>
|
|
||||||
toFrequency(getWordsCount(contractToText(contract)))
|
|
||||||
|
|
||||||
const contractsToWordFrequency = (contracts: Contract[]) => {
|
|
||||||
const frequencySum = contracts
|
|
||||||
.map(contractToWordFrequency)
|
|
||||||
.reduce(addObjects, {})
|
|
||||||
|
|
||||||
return toFrequency(frequencySum)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWordScores = (
|
|
||||||
contracts: Contract[],
|
|
||||||
contractViewCounts: { [contractId: string]: number },
|
|
||||||
clicks: ClickEvent[],
|
|
||||||
bets: Bet[]
|
|
||||||
) => {
|
|
||||||
const contractClicks = groupBy(clicks, (click) => click.contractId)
|
|
||||||
const contractBets = groupBy(bets, (bet) => bet.contractId)
|
|
||||||
|
|
||||||
const yourContracts = contracts.filter(
|
|
||||||
(c) =>
|
|
||||||
contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id]
|
|
||||||
)
|
|
||||||
const yourTfIdf = calculateContractTfIdf(yourContracts)
|
|
||||||
|
|
||||||
const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
|
|
||||||
const viewCount = contractViewCounts[contractId] ?? 0
|
|
||||||
const clickCount = contractClicks[contractId]?.length ?? 0
|
|
||||||
const betCount = contractBets[contractId]?.length ?? 0
|
|
||||||
|
|
||||||
const factor =
|
|
||||||
-1 * Math.log(viewCount + 1) +
|
|
||||||
10 * Math.log(betCount + clickCount / 4 + 1)
|
|
||||||
|
|
||||||
return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
|
|
||||||
})
|
|
||||||
|
|
||||||
const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
|
|
||||||
const minScore = Math.min(...Object.values(wordScores))
|
|
||||||
const maxScore = Math.max(...Object.values(wordScores))
|
|
||||||
const normalizedWordScores = mapValues(
|
|
||||||
wordScores,
|
|
||||||
(score) => (score - minScore) / (maxScore - minScore)
|
|
||||||
)
|
|
||||||
|
|
||||||
// console.log(
|
|
||||||
// 'your word scores',
|
|
||||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100),
|
|
||||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100)
|
|
||||||
// )
|
|
||||||
|
|
||||||
return normalizedWordScores
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContractScore(
|
|
||||||
contract: Contract,
|
|
||||||
wordScores: { [word: string]: number }
|
|
||||||
) {
|
|
||||||
if (Object.keys(wordScores).length === 0) return 1
|
|
||||||
|
|
||||||
const wordFrequency = contractToWordFrequency(contract)
|
|
||||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
|
||||||
const wordFreq = wordFrequency[word] ?? 0
|
|
||||||
const weight = wordScores[word] ?? 0
|
|
||||||
return wordFreq * weight
|
|
||||||
})
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF):
|
|
||||||
// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736
|
|
||||||
function calculateContractTfIdf(contracts: Contract[]) {
|
|
||||||
const contractFreq = contracts.map((c) => contractToWordFrequency(c))
|
|
||||||
const contractWords = contractFreq.map((freq) => Object.keys(freq))
|
|
||||||
|
|
||||||
const wordsCount: { [word: string]: number } = {}
|
|
||||||
for (const words of contractWords) {
|
|
||||||
for (const word of words) {
|
|
||||||
wordsCount[word] = (wordsCount[word] ?? 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordIdf = mapValues(wordsCount, (count) =>
|
|
||||||
Math.log(contracts.length / count)
|
|
||||||
)
|
|
||||||
const contractWordsTfIdf = contractFreq.map((wordFreq) =>
|
|
||||||
mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
|
|
||||||
)
|
|
||||||
return Object.fromEntries(
|
|
||||||
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
amount: number
|
amount: number
|
||||||
token: 'M$' // | 'USD' | MarketOutcome
|
token: 'M$' // | 'USD' | MarketOutcome
|
||||||
|
|
||||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
category:
|
||||||
|
| 'CHARITY'
|
||||||
|
| 'MANALINK'
|
||||||
|
| 'TIP'
|
||||||
|
| 'REFERRAL'
|
||||||
|
| 'UNIQUE_BETTOR_BONUS'
|
||||||
|
| 'BETTING_STREAK_BONUS'
|
||||||
|
|
||||||
// Any extra data
|
// Any extra data
|
||||||
data?: { [key: string]: any }
|
data?: { [key: string]: any }
|
||||||
|
@ -57,7 +63,7 @@ type Referral = {
|
||||||
type Bonus = {
|
type Bonus = {
|
||||||
fromType: 'BANK'
|
fromType: 'BANK'
|
||||||
toType: 'USER'
|
toType: 'USER'
|
||||||
category: 'UNIQUE_BETTOR_BONUS'
|
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DonationTxn = Txn & Donation
|
export type DonationTxn = Txn & Donation
|
||||||
|
|
|
@ -41,6 +41,8 @@ export type User = {
|
||||||
referredByGroupId?: string
|
referredByGroupId?: string
|
||||||
lastPingTime?: number
|
lastPingTime?: number
|
||||||
shouldShowWelcome?: boolean
|
shouldShowWelcome?: boolean
|
||||||
|
lastBetTime?: number
|
||||||
|
currentBettingStreak?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
|
@ -57,6 +59,7 @@ export type PrivateUser = {
|
||||||
unsubscribedFromCommentEmails?: boolean
|
unsubscribedFromCommentEmails?: boolean
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
unsubscribedFromAnswerEmails?: boolean
|
||||||
unsubscribedFromGenericEmails?: boolean
|
unsubscribedFromGenericEmails?: boolean
|
||||||
|
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||||
manaBonusEmailSent?: boolean
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
|
|
|
@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||||
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||||
|
- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API
|
||||||
|
|
||||||
## Bots
|
## Bots
|
||||||
|
|
||||||
|
|
|
@ -63,7 +63,7 @@ service cloud.firestore {
|
||||||
allow read: if userId == request.auth.uid || isAdmin();
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (userId == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]);
|
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId}/views/{viewId} {
|
match /private-users/{userId}/views/{viewId} {
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mailgun-js": "0.22.0",
|
"mailgun-js": "0.22.0",
|
||||||
"module-alias": "2.2.2",
|
"module-alias": "2.2.2",
|
||||||
|
"react-masonry-css": "1.0.16",
|
||||||
"stripe": "8.194.0",
|
"stripe": "8.194.0",
|
||||||
"zod": "3.17.2"
|
"zod": "3.17.2"
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet'
|
||||||
import { createChallengeAcceptedNotification } from './create-notification'
|
import { createChallengeAcceptedNotification } from './create-notification'
|
||||||
import { noFees } from '../../common/fees'
|
import { noFees } from '../../common/fees'
|
||||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||||
|
import { redeemShares } from './redeem-shares'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => {
|
||||||
return yourNewBetDoc
|
return yourNewBetDoc
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await redeemShares(auth.uid, contractId)
|
||||||
|
|
||||||
return { betId: result.id }
|
return { betId: result.id }
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,13 +23,8 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||||
type KeyCredentials = { kind: 'key'; data: string }
|
type KeyCredentials = { kind: 'key'; data: string }
|
||||||
type Credentials = JwtCredentials | KeyCredentials
|
type Credentials = JwtCredentials | KeyCredentials
|
||||||
|
|
||||||
const auth = admin.auth()
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
const privateUsers = firestore.collection(
|
|
||||||
'private-users'
|
|
||||||
) as admin.firestore.CollectionReference<PrivateUser>
|
|
||||||
|
|
||||||
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||||
|
const auth = admin.auth()
|
||||||
const authHeader = req.get('Authorization')
|
const authHeader = req.get('Authorization')
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new APIError(403, 'Missing Authorization header.')
|
throw new APIError(403, 'Missing Authorization header.')
|
||||||
|
@ -57,6 +52,8 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const privateUsers = firestore.collection('private-users')
|
||||||
switch (creds.kind) {
|
switch (creds.kind) {
|
||||||
case 'jwt': {
|
case 'jwt': {
|
||||||
if (typeof creds.data.user_id !== 'string') {
|
if (typeof creds.data.user_id !== 'string') {
|
||||||
|
@ -70,7 +67,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||||
if (privateUserQ.empty) {
|
if (privateUserQ.empty) {
|
||||||
throw new APIError(403, `No private user exists with API key ${key}.`)
|
throw new APIError(403, `No private user exists with API key ${key}.`)
|
||||||
}
|
}
|
||||||
const privateUser = privateUserQ.docs[0].data()
|
const privateUser = privateUserQ.docs[0].data() as PrivateUser
|
||||||
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -21,6 +21,7 @@ const bodySchema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const creategroup = newEndpoint({}, async (req, auth) => {
|
export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
const { name, about, memberIds, anyoneCanJoin } = validate(
|
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||||
bodySchema,
|
bodySchema,
|
||||||
req.body
|
req.body
|
||||||
|
@ -67,7 +68,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
return { status: 'success', group: group }
|
return { status: 'success', group: group }
|
||||||
})
|
})
|
||||||
|
|
||||||
const getSlug = async (name: string) => {
|
export const getSlug = async (name: string) => {
|
||||||
const proposedSlug = slugify(name)
|
const proposedSlug = slugify(name)
|
||||||
|
|
||||||
const preexistingGroup = await getGroupFromSlug(proposedSlug)
|
const preexistingGroup = await getGroupFromSlug(proposedSlug)
|
||||||
|
@ -75,9 +76,8 @@ const getSlug = async (name: string) => {
|
||||||
return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug
|
return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug
|
||||||
}
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
|
|
||||||
export async function getGroupFromSlug(slug: string) {
|
export async function getGroupFromSlug(slug: string) {
|
||||||
|
const firestore = admin.firestore()
|
||||||
const snap = await firestore
|
const snap = await firestore
|
||||||
.collection('groups')
|
.collection('groups')
|
||||||
.where('slug', '==', slug)
|
.where('slug', '==', slug)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
MultipleChoiceContract,
|
MultipleChoiceContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
OUTCOME_TYPES,
|
OUTCOME_TYPES,
|
||||||
|
VISIBILITIES,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
|
@ -67,6 +68,7 @@ const bodySchema = z.object({
|
||||||
),
|
),
|
||||||
outcomeType: z.enum(OUTCOME_TYPES),
|
outcomeType: z.enum(OUTCOME_TYPES),
|
||||||
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
|
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
|
||||||
|
visibility: z.enum(VISIBILITIES).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const binarySchema = z.object({
|
const binarySchema = z.object({
|
||||||
|
@ -88,8 +90,15 @@ const multipleChoiceSchema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
const {
|
||||||
validate(bodySchema, req.body)
|
question,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
closeTime,
|
||||||
|
outcomeType,
|
||||||
|
groupId,
|
||||||
|
visibility = 'public',
|
||||||
|
} = validate(bodySchema, req.body)
|
||||||
|
|
||||||
let min, max, initialProb, isLogScale, answers
|
let min, max, initialProb, isLogScale, answers
|
||||||
|
|
||||||
|
@ -194,7 +203,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
min ?? 0,
|
min ?? 0,
|
||||||
max ?? 0,
|
max ?? 0,
|
||||||
isLogScale ?? false,
|
isLogScale ?? false,
|
||||||
answers ?? []
|
answers ?? [],
|
||||||
|
visibility
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ante) await chargeUser(user.id, ante, true)
|
if (ante) await chargeUser(user.id, ante, true)
|
|
@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async (
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createBettingStreakBonusNotification = async (
|
||||||
|
user: User,
|
||||||
|
txnId: string,
|
||||||
|
bet: Bet,
|
||||||
|
contract: Contract,
|
||||||
|
amount: number,
|
||||||
|
idempotencyKey: string
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${user.id}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: user.id,
|
||||||
|
reason: 'betting_streak_incremented',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: txnId,
|
||||||
|
sourceType: 'betting_streak_bonus',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: user.name,
|
||||||
|
sourceUserUsername: user.username,
|
||||||
|
sourceUserAvatarUrl: user.avatarUrl,
|
||||||
|
sourceText: amount.toString(),
|
||||||
|
sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`,
|
||||||
|
sourceTitle: 'Betting Streak Bonus',
|
||||||
|
// Perhaps not necessary, but just in case
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceContractId: contract.id,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
cleanDisplayName,
|
cleanDisplayName,
|
||||||
cleanUsername,
|
cleanUsername,
|
||||||
} from '../../common/util/clean-username'
|
} from '../../common/util/clean-username'
|
||||||
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
|
||||||
import { isWhitelisted } from '../../common/envs/constants'
|
import { isWhitelisted } from '../../common/envs/constants'
|
||||||
import {
|
import {
|
||||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
CATEGORIES_GROUP_SLUG_POSTFIX,
|
||||||
|
@ -93,10 +92,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
||||||
await addUserToDefaultGroups(user)
|
await addUserToDefaultGroups(user)
|
||||||
await sendWelcomeEmail(user, privateUser)
|
|
||||||
await sendPersonalFollowupEmail(user, privateUser)
|
|
||||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||||
|
|
||||||
return { user, privateUser }
|
return { user, privateUser }
|
||||||
|
|
|
@ -103,94 +103,28 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
<div style="background-color: #f4f4f4">
|
|
||||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
<div style="
|
<div style="
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
margin: 0px auto;
|
margin: 0px auto;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
">
|
">
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="
|
<td style="width:550px;">
|
||||||
direction: ltr;
|
<a href="https://manifold.markets" target="_blank">
|
||||||
font-size: 0px;
|
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||||
padding: 0px 0px 0px 0px;
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
padding-bottom: 0px;
|
title="" width="550">
|
||||||
padding-left: 0px;
|
</a>
|
||||||
padding-right: 0px;
|
</td>
|
||||||
padding-top: 0px;
|
</tr>
|
||||||
text-align: center;
|
<tr>
|
||||||
">
|
<td style="
|
||||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
|
||||||
font-size: 0px;
|
|
||||||
text-align: left;
|
|
||||||
direction: ltr;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
width: 100%;
|
|
||||||
">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
|
||||||
width="100%">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 0px 25px 0px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-right: 25px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
padding-left: 25px;
|
|
||||||
word-break: break-word;
|
|
||||||
">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="
|
|
||||||
border-collapse: collapse;
|
|
||||||
border-spacing: 0px;
|
|
||||||
">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="width: 550px">
|
|
||||||
<a href="https://manifold.markets/home" target="_blank"><img alt="" height="auto"
|
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif" style="
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
" width="550" /></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
||||||
<div style="
|
|
||||||
background: #ffffff;
|
|
||||||
background-color: #ffffff;
|
|
||||||
margin: 0px auto;
|
|
||||||
max-width: 600px;
|
|
||||||
">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 20px 0px 0px 0px;
|
padding: 20px 0px 0px 0px;
|
||||||
|
@ -200,8 +134,8 @@
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
">
|
">
|
||||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
@ -209,24 +143,24 @@
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
">
|
">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
||||||
width="100%">
|
width="100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left"
|
<td align="left"
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
<p class="text-build-content"
|
<p class="text-build-content"
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Hi {{name}},</span></p>
|
Hi {{name}},</span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="
|
<td align="left" style="
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 0px 25px 20px 25px;
|
padding: 0px 25px 20px 25px;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
|
@ -235,7 +169,7 @@
|
||||||
padding-left: 25px;
|
padding-left: 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
">
|
">
|
||||||
<div style="
|
<div style="
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
|
@ -243,197 +177,253 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
">
|
">
|
||||||
<p class="text-build-content" style="
|
<p class="text-build-content" style="
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
" data-testid="3Q8BP69fq">
|
" data-testid="3Q8BP69fq">
|
||||||
<span style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">Congrats on creating your first market on <a class="link-build-content"
|
">Did you know you create your own prediction market on <a class="link-build-content"
|
||||||
style="color: #55575d" target="_blank"
|
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for
|
||||||
href="https://manifold.markets">Manifold</a>!</span>
|
any question you care about?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq">
|
<span style="
|
||||||
<span style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">The following is a short guide to creating markets.</span>
|
">Whether it's current events like <a class="link-build-content" style="color: #55575d"
|
||||||
</p>
|
target="_blank"
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
href="https://manifold.markets/SG/will-elon-musk-buy-twitter-this-yea">Musk buying
|
||||||
data-testid="3Q8BP69fq">
|
Twitter</a> or <a class="link-build-content" style="color: #55575d" target="_blank"
|
||||||
|
href="https://manifold.markets/NathanpmYoung/will-biden-be-the-2024-democratic-n">2024
|
||||||
</p>
|
elections</a> or personal matters
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
like <a class="link-build-content" style="color: #55575d" target="_blank"
|
||||||
data-testid="3Q8BP69fq">
|
href="https://manifold.markets/dreev/which-book-will-i-like-best">book
|
||||||
<span style="
|
recommendations</a> or <a class="link-build-content" style="color: #55575d"
|
||||||
|
target="_blank"
|
||||||
|
href="https://manifold.markets/agentydragon/will-my-weight-go-under-115-kg-in-2">losing
|
||||||
|
weight</a>,
|
||||||
|
Manifold can help you find the answer.</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0;margin-bottom: 20px;"
|
||||||
|
data-testid="3Q8BP69fq">
|
||||||
|
<span style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
">The following is a
|
||||||
|
short guide to creating markets.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0" align="center">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="https://manifold.markets/create" target="_blank"
|
||||||
|
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
Create a market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
|
<span style="
|
||||||
color: #292fd7;
|
color: #292fd7;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
"><b>What makes a good market?</b></span>
|
"><b>What makes a good market?</b></span>
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li style="line-height: 23px; margin-bottom: 8px;">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span
|
<span
|
||||||
style="font-family: Readex Pro, Arial, Helvetica, sans-serif;font-size: 17px;"><b>Interesting
|
style="font-family: Readex Pro, Arial, Helvetica, sans-serif;font-size: 17px;"><b>Interesting
|
||||||
topic. </b>Manifold gives
|
topic. </b>Manifold gives
|
||||||
creators M$10 for
|
creators M$10 for
|
||||||
each unique trader that bets on your
|
each unique trader that bets on your
|
||||||
market, so it pays to ask a question people are interested in!</span>
|
market, so it pays to ask a question people are interested in!</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li style="line-height: 23px; margin-bottom: 8px;">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><b>Clear resolution criteria. </b>Any ambiguities or edge cases in your description
|
"><b>Clear resolution criteria. </b>Any ambiguities or edge cases in your description
|
||||||
will drive traders away from your markets.</span>
|
will drive traders away from your markets.</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li style="line-height: 23px; margin-bottom: 8px;">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><b>Detailed description. </b>Include images/videos/tweets and any context or
|
"><b>Detailed description. </b>Include images/videos/tweets and any context or
|
||||||
background
|
background
|
||||||
information that could be useful to people who
|
information that could be useful to people who
|
||||||
are interested in learning more that are
|
are interested in learning more that are
|
||||||
uneducated on the subject.</span>
|
uneducated on the subject.</span>
|
||||||
</li>
|
</li>
|
||||||
<li style="line-height: 23px; margin-bottom: 8px;">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><b>Add it to a group. </b>Groups are the
|
"><b>Part of a group. </b>Groups are the
|
||||||
primary way users filter for relevant markets.
|
primary way users filter for relevant markets.
|
||||||
Also, consider making your own groups and
|
Also, consider making your own groups and
|
||||||
inviting friends/interested communities to
|
inviting friends/interested communities to
|
||||||
them from other sites!</span>
|
them from other sites!</span>
|
||||||
</li>
|
</li>
|
||||||
<li style="line-height: 23px; margin-bottom: 8px;">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><b>Share it on social media</b>. You'll earn the <a class="link-build-content"
|
"><b>Sharing it on social media</b>. You'll earn the <a class="link-build-content"
|
||||||
style="color: inherit; text-decoration: none" target="_blank"
|
style="color: inherit; text-decoration: none" target="_blank"
|
||||||
href="https://manifold.markets/referrals"><span style="
|
href="https://manifold.markets/referrals"><span style="
|
||||||
color: #55575d;
|
color: #55575d;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><u>M$500
|
"><u>M$500
|
||||||
referral bonus</u></span></a> if you get new users to sign up!</span>
|
referral bonus</u></span></a> if you get new users to sign up!</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq">
|
|
||||||
|
</p>
|
||||||
</p>
|
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq">
|
<span style="
|
||||||
<span style="
|
|
||||||
color: #292fd7;
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
|
||||||
sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
"><b>Examples of markets you should
|
|
||||||
emulate! </b></span>
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li style="line-height: 23px">
|
|
||||||
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
|
||||||
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"><span
|
|
||||||
style="
|
|
||||||
color: #55575d;
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
|
||||||
sans-serif;
|
|
||||||
font-size: 17px;
|
|
||||||
"><u>This complex market</u></span></a><span style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
|
||||||
sans-serif;
|
|
||||||
font-size: 17px;
|
|
||||||
">
|
|
||||||
about the project I am working on.</span>
|
|
||||||
</li>
|
|
||||||
<li style="line-height: 23px">
|
|
||||||
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
|
||||||
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"><span
|
|
||||||
style="
|
|
||||||
color: #55575d;
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
|
||||||
sans-serif;
|
|
||||||
font-size: 17px;
|
|
||||||
"><u>This simple market</u></span></a><span style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
|
||||||
sans-serif;
|
|
||||||
font-size: 17px;
|
|
||||||
">
|
|
||||||
about Manifold's weekly active
|
|
||||||
users.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq">
|
|
||||||
<span style="
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">Why not </span>
|
">Why not </span>
|
||||||
|
<a class="link-build-content" style="color: inherit; text-decoration: none" target="_blank"
|
||||||
|
href="https://manifold.markets/create"><span style="
|
||||||
|
|
||||||
<a class="link-build-content" style="color: inherit; text-decoration: none" target="_blank"
|
|
||||||
href="https://manifold.markets/create"><span style="
|
|
||||||
color: #55575d;
|
color: #55575d;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"><u>create another market</u></span></a><span style="
|
"><u>create a market</u></span></a><span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">
|
">
|
||||||
while it is still fresh on your mind?
|
while it is still fresh on your mind?
|
||||||
</p>
|
</p>
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq">
|
|
||||||
<span style="
|
</p>
|
||||||
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||||
|
<span style="
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">Thanks for reading!</span>
|
">Thanks for reading!</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-build-content" style="
|
<p class="text-build-content" style="
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
" data-testid="3Q8BP69fq">
|
" data-testid="3Q8BP69fq">
|
||||||
<span style="
|
<span style="
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">David from Manifold</span>
|
">David from Manifold</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f4f4f4">
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 0px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0px 25px 0px 25px;
|
||||||
|
padding-top: 0px;
|
||||||
|
padding-right: 25px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0px;
|
||||||
|
">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width: 550px">
|
||||||
|
<a href="https://manifold.markets/create" target="_blank"><img alt="" height="auto"
|
||||||
|
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif" style="
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
" width="550" /></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
476
functions/src/email-templates/interesting-markets.html
Normal file
476
functions/src/email-templates/interesting-markets.html
Normal file
|
@ -0,0 +1,476 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Interesting markets on Manifold</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG />
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:550px;">
|
||||||
|
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
|
||||||
|
<img alt="banner logo" height="auto"
|
||||||
|
src="https://manifold.markets/logo-banner.png"
|
||||||
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
|
title="" width="550">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align: top" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
Here is a selection of markets on Manifold you might find
|
||||||
|
interesting!</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question1Link}}">
|
||||||
|
<img alt="{{question1Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question1ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question1Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question2Link}}">
|
||||||
|
<img alt="{{question2Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question2ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question2Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question3Link}}">
|
||||||
|
<img alt="{{question3Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question3ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question3Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question4Link}}">
|
||||||
|
<img alt="{{question4Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question4ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question4Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question5Link}}">
|
||||||
|
<img alt="{{question5Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question5ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question5Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{question6Link}}">
|
||||||
|
<img alt="{{question6Title}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{question6ImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{question6Link}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 20px 0px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
">
|
||||||
|
<p style="margin: 10px 0">
|
||||||
|
This e-mail has been sent to
|
||||||
|
{{name}},
|
||||||
|
<a href="{{unsubscribeLink}}"
|
||||||
|
style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to unsubscribe</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -107,19 +107,12 @@
|
||||||
width="100%">
|
width="100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"
|
<td style="width:550px;">
|
||||||
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
<a href="https://manifold.markets" target="_blank">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||||
style="border-collapse:collapse;border-spacing:0px;">
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
<tbody>
|
title="" width="550">
|
||||||
<tr>
|
</a>
|
||||||
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
|
||||||
alt="" height="auto" src="https://03jlj.mjt.lu/img/03jlj/b/urx/sjtz.gif"
|
|
||||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
|
||||||
width="550"></a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -175,9 +168,9 @@
|
||||||
<td>
|
<td>
|
||||||
<table cellspacing="0" cellpadding="0">
|
<table cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
<a href="https://manifold.markets" target="_blank"
|
<a href="https://manifold.markets" target="_blank"
|
||||||
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
|
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
Explore markets
|
Explore markets
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
@ -225,22 +218,12 @@
|
||||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
|
||||||
chat</u></span></a></span></li>
|
chat</u></span></a></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
|
||||||
from Manifold</span></p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left"
|
<td align="left"
|
||||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import * as dayjs from 'dayjs'
|
|
||||||
|
|
||||||
import { DOMAIN } from '../../common/envs/constants'
|
import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
@ -20,6 +18,7 @@ import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getPrivateUser, getUser } from './utils'
|
||||||
import { getFunctionUrl } from '../../common/api'
|
import { getFunctionUrl } from '../../common/api'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
import { richTextToString } from '../../common/util/parse'
|
||||||
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
|
|
||||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
||||||
|
|
||||||
|
@ -169,7 +168,8 @@ export const sendWelcomeEmail = async (
|
||||||
|
|
||||||
export const sendPersonalFollowupEmail = async (
|
export const sendPersonalFollowupEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser,
|
||||||
|
sendTime: string
|
||||||
) => {
|
) => {
|
||||||
if (!privateUser || !privateUser.email) return
|
if (!privateUser || !privateUser.email) return
|
||||||
|
|
||||||
|
@ -191,8 +191,6 @@ Cofounder of Manifold Markets
|
||||||
https://manifold.markets
|
https://manifold.markets
|
||||||
`
|
`
|
||||||
|
|
||||||
const sendTime = dayjs().add(4, 'hours').toString()
|
|
||||||
|
|
||||||
await sendTextEmail(
|
await sendTextEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'How are you finding Manifold?',
|
'How are you finding Manifold?',
|
||||||
|
@ -238,7 +236,8 @@ export const sendOneWeekBonusEmail = async (
|
||||||
|
|
||||||
export const sendCreatorGuideEmail = async (
|
export const sendCreatorGuideEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser,
|
||||||
|
sendTime: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
|
@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Market creation guide',
|
'Create your own prediction market',
|
||||||
'creating-market',
|
'creating-market',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
|
@ -263,6 +262,7 @@ export const sendCreatorGuideEmail = async (
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
'o:deliverytime': sendTime,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -460,3 +460,61 @@ export const sendNewAnswerEmail = async (
|
||||||
{ from }
|
{ from }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendInterestingMarketsEmail = async (
|
||||||
|
user: User,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
contractsToSend: Contract[],
|
||||||
|
deliveryTime?: string
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!privateUser ||
|
||||||
|
!privateUser.email ||
|
||||||
|
privateUser?.unsubscribedFromWeeklyTrendingEmails
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const emailType = 'weekly-trending'
|
||||||
|
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
|
await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
`${contractsToSend[0].question} & 5 more interesting markets on Manifold`,
|
||||||
|
'interesting-markets',
|
||||||
|
{
|
||||||
|
name: firstName,
|
||||||
|
unsubscribeLink: unsubscribeUrl,
|
||||||
|
|
||||||
|
question1Title: contractsToSend[0].question,
|
||||||
|
question1Link: contractUrl(contractsToSend[0]),
|
||||||
|
question1ImgSrc: imageSourceUrl(contractsToSend[0]),
|
||||||
|
question2Title: contractsToSend[1].question,
|
||||||
|
question2Link: contractUrl(contractsToSend[1]),
|
||||||
|
question2ImgSrc: imageSourceUrl(contractsToSend[1]),
|
||||||
|
question3Title: contractsToSend[2].question,
|
||||||
|
question3Link: contractUrl(contractsToSend[2]),
|
||||||
|
question3ImgSrc: imageSourceUrl(contractsToSend[2]),
|
||||||
|
question4Title: contractsToSend[3].question,
|
||||||
|
question4Link: contractUrl(contractsToSend[3]),
|
||||||
|
question4ImgSrc: imageSourceUrl(contractsToSend[3]),
|
||||||
|
question5Title: contractsToSend[4].question,
|
||||||
|
question5Link: contractUrl(contractsToSend[4]),
|
||||||
|
question5ImgSrc: imageSourceUrl(contractsToSend[4]),
|
||||||
|
question6Title: contractsToSend[5].question,
|
||||||
|
question6Link: contractUrl(contractsToSend[5]),
|
||||||
|
question6ImgSrc: imageSourceUrl(contractsToSend[5]),
|
||||||
|
},
|
||||||
|
deliveryTime ? { 'o:deliverytime': deliveryTime } : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractUrl(contract: Contract) {
|
||||||
|
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSourceUrl(contract: Contract) {
|
||||||
|
return buildCardUrl(getOpenGraphProps(contract))
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { EndpointDefinition } from './api'
|
||||||
admin.initializeApp()
|
admin.initializeApp()
|
||||||
|
|
||||||
// v1
|
// v1
|
||||||
|
export * from './on-create-user'
|
||||||
export * from './on-create-bet'
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment-on-contract'
|
export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
|
@ -25,6 +26,8 @@ export * from './on-create-comment-on-group'
|
||||||
export * from './on-create-txn'
|
export * from './on-create-txn'
|
||||||
export * from './on-delete-group'
|
export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
|
export * from './weekly-markets-emails'
|
||||||
|
export * from './reset-betting-streaks'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -37,7 +40,7 @@ export * from './cancel-bet'
|
||||||
export * from './sell-bet'
|
export * from './sell-bet'
|
||||||
export * from './sell-shares'
|
export * from './sell-shares'
|
||||||
export * from './claim-manalink'
|
export * from './claim-manalink'
|
||||||
export * from './create-contract'
|
export * from './create-market'
|
||||||
export * from './add-liquidity'
|
export * from './add-liquidity'
|
||||||
export * from './withdraw-liquidity'
|
export * from './withdraw-liquidity'
|
||||||
export * from './create-group'
|
export * from './create-group'
|
||||||
|
@ -56,7 +59,7 @@ import { cancelbet } from './cancel-bet'
|
||||||
import { sellbet } from './sell-bet'
|
import { sellbet } from './sell-bet'
|
||||||
import { sellshares } from './sell-shares'
|
import { sellshares } from './sell-shares'
|
||||||
import { claimmanalink } from './claim-manalink'
|
import { claimmanalink } from './claim-manalink'
|
||||||
import { createmarket } from './create-contract'
|
import { createmarket } from './create-market'
|
||||||
import { addliquidity } from './add-liquidity'
|
import { addliquidity } from './add-liquidity'
|
||||||
import { withdrawliquidity } from './withdraw-liquidity'
|
import { withdrawliquidity } from './withdraw-liquidity'
|
||||||
import { creategroup } from './create-group'
|
import { creategroup } from './create-group'
|
||||||
|
|
|
@ -3,15 +3,21 @@ import * as admin from 'firebase-admin'
|
||||||
import { keyBy, uniq } from 'lodash'
|
import { keyBy, uniq } from 'lodash'
|
||||||
|
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
import { getContract, getUser, getValues, isProd, log } from './utils'
|
import { getUser, getValues, isProd, log } from './utils'
|
||||||
import {
|
import {
|
||||||
createBetFillNotification,
|
createBetFillNotification,
|
||||||
|
createBettingStreakBonusNotification,
|
||||||
createNotification,
|
createNotification,
|
||||||
} from './create-notification'
|
} from './create-notification'
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { runTxn, TxnData } from './transact'
|
import { runTxn, TxnData } from './transact'
|
||||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
|
import {
|
||||||
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
|
BETTING_STREAK_BONUS_MAX,
|
||||||
|
BETTING_STREAK_RESET_HOUR,
|
||||||
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
|
} from '../../common/numeric-constants'
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
@ -38,37 +44,99 @@ export const onCreateBet = functions.firestore
|
||||||
.doc(contractId)
|
.doc(contractId)
|
||||||
.update({ lastBetTime, lastUpdatedTime: Date.now() })
|
.update({ lastBetTime, lastUpdatedTime: Date.now() })
|
||||||
|
|
||||||
await notifyFills(bet, contractId, eventId)
|
const userContractSnap = await firestore
|
||||||
await updateUniqueBettorsAndGiveCreatorBonus(
|
.collection(`contracts`)
|
||||||
contractId,
|
.doc(contractId)
|
||||||
eventId,
|
.get()
|
||||||
bet.userId
|
const contract = userContractSnap.data() as Contract
|
||||||
)
|
|
||||||
|
if (!contract) {
|
||||||
|
log(`Could not find contract ${contractId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
|
||||||
|
|
||||||
|
const bettor = await getUser(bet.userId)
|
||||||
|
if (!bettor) return
|
||||||
|
|
||||||
|
await notifyFills(bet, contract, eventId, bettor)
|
||||||
|
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||||
|
|
||||||
|
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateBettingStreak = async (
|
||||||
|
user: User,
|
||||||
|
bet: Bet,
|
||||||
|
contract: Contract,
|
||||||
|
eventId: string
|
||||||
|
) => {
|
||||||
|
const betStreakResetTime = getTodaysBettingStreakResetTime()
|
||||||
|
const lastBetTime = user?.lastBetTime ?? 0
|
||||||
|
|
||||||
|
// If they've already bet after the reset time, or if we haven't hit the reset time yet
|
||||||
|
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
|
||||||
|
return
|
||||||
|
|
||||||
|
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
||||||
|
// Otherwise, add 1 to their betting streak
|
||||||
|
await firestore.collection('users').doc(user.id).update({
|
||||||
|
currentBettingStreak: newBettingStreak,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send them the bonus times their streak
|
||||||
|
const bonusAmount = Math.min(
|
||||||
|
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
||||||
|
BETTING_STREAK_BONUS_MAX
|
||||||
|
)
|
||||||
|
const fromUserId = isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
const bonusTxnDetails = {
|
||||||
|
currentBettingStreak: newBettingStreak,
|
||||||
|
}
|
||||||
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
|
const bonusTxn: TxnData = {
|
||||||
|
fromId: fromUserId,
|
||||||
|
fromType: 'BANK',
|
||||||
|
toId: user.id,
|
||||||
|
toType: 'USER',
|
||||||
|
amount: bonusAmount,
|
||||||
|
token: 'M$',
|
||||||
|
category: 'BETTING_STREAK_BONUS',
|
||||||
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
|
}
|
||||||
|
return await runTxn(trans, bonusTxn)
|
||||||
|
})
|
||||||
|
if (!result.txn) {
|
||||||
|
log("betting streak bonus txn couldn't be made")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await createBettingStreakBonusNotification(
|
||||||
|
user,
|
||||||
|
result.txn.id,
|
||||||
|
bet,
|
||||||
|
contract,
|
||||||
|
bonusAmount,
|
||||||
|
eventId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
contractId: string,
|
contract: Contract,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
bettorId: string
|
bettorId: string
|
||||||
) => {
|
) => {
|
||||||
const userContractSnap = await firestore
|
|
||||||
.collection(`contracts`)
|
|
||||||
.doc(contractId)
|
|
||||||
.get()
|
|
||||||
const contract = userContractSnap.data() as Contract
|
|
||||||
if (!contract) {
|
|
||||||
log(`Could not find contract ${contractId}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let previousUniqueBettorIds = contract.uniqueBettorIds
|
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||||
|
|
||||||
if (!previousUniqueBettorIds) {
|
if (!previousUniqueBettorIds) {
|
||||||
const contractBets = (
|
const contractBets = (
|
||||||
await firestore.collection(`contracts/${contractId}/bets`).get()
|
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
||||||
).docs.map((doc) => doc.data() as Bet)
|
).docs.map((doc) => doc.data() as Bet)
|
||||||
|
|
||||||
if (contractBets.length === 0) {
|
if (contractBets.length === 0) {
|
||||||
log(`No bets for contract ${contractId}`)
|
log(`No bets for contract ${contract.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,7 +154,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
|
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
|
||||||
await firestore.collection(`contracts`).doc(contractId).update({
|
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
})
|
})
|
||||||
|
@ -97,7 +165,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
|
||||||
// Create combined txn for all new unique bettors
|
// Create combined txn for all new unique bettors
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
contractId: contractId,
|
contractId: contract.id,
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
}
|
}
|
||||||
const fromUserId = isProd()
|
const fromUserId = isProd()
|
||||||
|
@ -140,14 +208,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
|
const notifyFills = async (
|
||||||
|
bet: Bet,
|
||||||
|
contract: Contract,
|
||||||
|
eventId: string,
|
||||||
|
user: User
|
||||||
|
) => {
|
||||||
if (!bet.fills) return
|
if (!bet.fills) return
|
||||||
|
|
||||||
const user = await getUser(bet.userId)
|
|
||||||
if (!user) return
|
|
||||||
const contract = await getContract(contractId)
|
|
||||||
if (!contract) return
|
|
||||||
|
|
||||||
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
|
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
|
||||||
const matchedBets = (
|
const matchedBets = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
@ -180,3 +248,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTodaysBettingStreakResetTime = () => {
|
||||||
|
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { compact, uniq } from 'lodash'
|
import { compact, uniq } from 'lodash'
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import { getContract, getUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
|
@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions
|
||||||
contractQuestion: contract.question,
|
contractQuestion: contract.question,
|
||||||
})
|
})
|
||||||
|
|
||||||
const comment = change.data() as Comment
|
const comment = change.data() as ContractComment
|
||||||
const lastCommentTime = comment.createdTime
|
const lastCommentTime = comment.createdTime
|
||||||
|
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
|
@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<ContractComment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
)
|
)
|
||||||
const relatedSourceType = comment.replyToCommentId
|
const relatedSourceType = comment.replyToCommentId
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { Comment } from '../../common/comment'
|
import { GroupComment } from '../../common/comment'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore
|
||||||
groupId: string
|
groupId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = change.data() as Comment
|
const comment = change.data() as GroupComment
|
||||||
const creatorSnapshot = await firestore
|
const creatorSnapshot = await firestore
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(comment.userId)
|
.doc(comment.userId)
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
|
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { sendCreatorGuideEmail } from './emails'
|
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -31,23 +28,4 @@ export const onCreateContract = functions
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
{ contract, recipients: mentioned }
|
{ contract, recipients: mentioned }
|
||||||
)
|
)
|
||||||
|
|
||||||
await sendGuideEmail(contractCreator)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
|
|
||||||
const sendGuideEmail = async (contractCreator: User) => {
|
|
||||||
const query = await firestore
|
|
||||||
.collection(`contracts`)
|
|
||||||
.where('creatorId', '==', contractCreator.id)
|
|
||||||
.limit(2)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
if (query.size >= 2) return
|
|
||||||
|
|
||||||
const privateUser = await getPrivateUser(contractCreator.id)
|
|
||||||
if (!privateUser) return
|
|
||||||
|
|
||||||
await sendCreatorGuideEmail(contractCreator, privateUser)
|
|
||||||
}
|
|
||||||
|
|
45
functions/src/on-create-user.ts
Normal file
45
functions/src/on-create-user.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as dayjs from 'dayjs'
|
||||||
|
import * as utc from 'dayjs/plugin/utc'
|
||||||
|
dayjs.extend(utc)
|
||||||
|
|
||||||
|
import { getPrivateUser } from './utils'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import {
|
||||||
|
sendCreatorGuideEmail,
|
||||||
|
sendInterestingMarketsEmail,
|
||||||
|
sendPersonalFollowupEmail,
|
||||||
|
sendWelcomeEmail,
|
||||||
|
} from './emails'
|
||||||
|
import { getTrendingContracts } from './weekly-markets-emails'
|
||||||
|
|
||||||
|
export const onCreateUser = functions
|
||||||
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
.firestore.document('users/{userId}')
|
||||||
|
.onCreate(async (snapshot) => {
|
||||||
|
const user = snapshot.data() as User
|
||||||
|
const privateUser = await getPrivateUser(user.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
|
||||||
|
await sendWelcomeEmail(user, privateUser)
|
||||||
|
|
||||||
|
const guideSendTime = dayjs().add(28, 'hours').toString()
|
||||||
|
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
|
||||||
|
|
||||||
|
const followupSendTime = dayjs().add(48, 'hours').toString()
|
||||||
|
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
|
||||||
|
|
||||||
|
// skip email if weekly email is about to go out
|
||||||
|
const day = dayjs().utc().day()
|
||||||
|
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
|
||||||
|
|
||||||
|
const contracts = await getTrendingContracts()
|
||||||
|
const marketsSendTime = dayjs().add(24, 'hours').toString()
|
||||||
|
|
||||||
|
await sendInterestingMarketsEmail(
|
||||||
|
user,
|
||||||
|
privateUser,
|
||||||
|
contracts,
|
||||||
|
marketsSendTime
|
||||||
|
)
|
||||||
|
})
|
39
functions/src/reset-betting-streaks.ts
Normal file
39
functions/src/reset-betting-streaks.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
|
||||||
|
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { User } from '../../common/user'
|
||||||
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const resetBettingStreaksForUsers = functions.pubsub
|
||||||
|
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||||
|
.timeZone('utc')
|
||||||
|
.onRun(async () => {
|
||||||
|
await resetBettingStreaksInternal()
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetBettingStreaksInternal = async () => {
|
||||||
|
const usersSnap = await firestore.collection('users').get()
|
||||||
|
|
||||||
|
const users = usersSnap.docs.map((doc) => doc.data() as User)
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await resetBettingStreakForUser(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBettingStreakForUser = async (user: User) => {
|
||||||
|
const betStreakResetTime = Date.now() - DAY_MS
|
||||||
|
// if they made a bet within the last day, don't reset their streak
|
||||||
|
if (
|
||||||
|
(user.lastBetTime ?? 0 > betStreakResetTime) ||
|
||||||
|
!user.currentBettingStreak ||
|
||||||
|
user.currentBettingStreak === 0
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await firestore.collection('users').doc(user.id).update({
|
||||||
|
currentBettingStreak: 0,
|
||||||
|
})
|
||||||
|
}
|
31
functions/src/scripts/backfill-comment-types.ts
Normal file
31
functions/src/scripts/backfill-comment-types.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// Comment types were introduced in August 2022.
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { log, writeAsync } from '../utils'
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const app = initAdmin()
|
||||||
|
const firestore = app.firestore()
|
||||||
|
const commentsRef = firestore.collectionGroup('comments')
|
||||||
|
commentsRef.get().then(async (commentsSnaps) => {
|
||||||
|
log(`Loaded ${commentsSnaps.size} comments.`)
|
||||||
|
const needsFilling = commentsSnaps.docs.filter((ct) => {
|
||||||
|
return !('commentType' in ct.data())
|
||||||
|
})
|
||||||
|
log(`Found ${needsFilling.length} comments to update.`)
|
||||||
|
const updates = needsFilling.map((d) => {
|
||||||
|
const comment = d.data()
|
||||||
|
const fields: { [k: string]: unknown } = {}
|
||||||
|
if (comment.contractId != null && comment.groupId == null) {
|
||||||
|
fields.commentType = 'contract'
|
||||||
|
} else if (comment.groupId != null && comment.contractId == null) {
|
||||||
|
fields.commentType = 'group'
|
||||||
|
} else {
|
||||||
|
log(`Invalid comment ${comment}; not touching it.`)
|
||||||
|
}
|
||||||
|
return { doc: d.ref, fields, info: comment }
|
||||||
|
})
|
||||||
|
await writeAsync(firestore, updates)
|
||||||
|
log(`Updated all comments.`)
|
||||||
|
})
|
||||||
|
}
|
66
functions/src/scripts/convert-tag-to-group.ts
Normal file
66
functions/src/scripts/convert-tag-to-group.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
// Takes a tag and makes a new group with all the contracts in it.
|
||||||
|
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { isProd, log } from '../utils'
|
||||||
|
import { getSlug } from '../create-group'
|
||||||
|
import { Group } from '../../../common/group'
|
||||||
|
|
||||||
|
const getTaggedContractIds = async (tag: string) => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const results = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
||||||
|
.get()
|
||||||
|
return results.docs.map((d) => d.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createGroup = async (
|
||||||
|
name: string,
|
||||||
|
about: string,
|
||||||
|
contractIds: string[]
|
||||||
|
) => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const creatorId = isProd()
|
||||||
|
? 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
|
||||||
|
: '94YYTk1AFWfbWMpfYcvnnwI1veP2'
|
||||||
|
|
||||||
|
const slug = await getSlug(name)
|
||||||
|
const groupRef = firestore.collection('groups').doc()
|
||||||
|
const now = Date.now()
|
||||||
|
const group: Group = {
|
||||||
|
id: groupRef.id,
|
||||||
|
creatorId,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
about,
|
||||||
|
createdTime: now,
|
||||||
|
mostRecentActivityTime: now,
|
||||||
|
contractIds: contractIds,
|
||||||
|
anyoneCanJoin: true,
|
||||||
|
memberIds: [],
|
||||||
|
}
|
||||||
|
return await groupRef.create(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertTagToGroup = async (tag: string, groupName: string) => {
|
||||||
|
log(`Looking up contract IDs with tag ${tag}...`)
|
||||||
|
const contractIds = await getTaggedContractIds(tag)
|
||||||
|
log(`${contractIds.length} contracts found.`)
|
||||||
|
if (contractIds.length > 0) {
|
||||||
|
log(`Creating group ${groupName}...`)
|
||||||
|
const about = `Contracts that used to be tagged ${tag}.`
|
||||||
|
const result = await createGroup(groupName, about, contractIds)
|
||||||
|
log(`Done. Group: `, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
initAdmin()
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
if (args.length != 2) {
|
||||||
|
console.log('Usage: convert-tag-to-group [tag] [group-name]')
|
||||||
|
} else {
|
||||||
|
convertTagToGroup(args[0], args[1]).catch((e) => console.error(e))
|
||||||
|
}
|
||||||
|
}
|
29
functions/src/scripts/unlist-contracts.ts
Normal file
29
functions/src/scripts/unlist-contracts.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
import { Contract } from '../../../common/contract'
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function unlistContracts() {
|
||||||
|
console.log('Updating some contracts to be unlisted')
|
||||||
|
|
||||||
|
const snapshot = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('groupSlugs', 'array-contains', 'fantasy-football-stock-exchange')
|
||||||
|
.get()
|
||||||
|
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
||||||
|
|
||||||
|
console.log('Loaded', contracts.length, 'contracts')
|
||||||
|
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const contractRef = firestore.doc(`contracts/${contract.id}`)
|
||||||
|
|
||||||
|
console.log('Updating', contract.question)
|
||||||
|
await contractRef.update({ visibility: 'unlisted' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) unlistContracts().then(() => process.exit())
|
|
@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet'
|
||||||
import { sellbet } from './sell-bet'
|
import { sellbet } from './sell-bet'
|
||||||
import { sellshares } from './sell-shares'
|
import { sellshares } from './sell-shares'
|
||||||
import { claimmanalink } from './claim-manalink'
|
import { claimmanalink } from './claim-manalink'
|
||||||
import { createmarket } from './create-contract'
|
import { createmarket } from './create-market'
|
||||||
import { addliquidity } from './add-liquidity'
|
import { addliquidity } from './add-liquidity'
|
||||||
import { withdrawliquidity } from './withdraw-liquidity'
|
import { withdrawliquidity } from './withdraw-liquidity'
|
||||||
import { creategroup } from './create-group'
|
import { creategroup } from './create-group'
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
'market-comment',
|
'market-comment',
|
||||||
'market-answer',
|
'market-answer',
|
||||||
'generic',
|
'generic',
|
||||||
|
'weekly-trending',
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
res.status(400).send('Invalid type parameter.')
|
res.status(400).send('Invalid type parameter.')
|
||||||
|
@ -49,6 +50,9 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
...(type === 'generic' && {
|
...(type === 'generic' && {
|
||||||
unsubscribedFromGenericEmails: true,
|
unsubscribedFromGenericEmails: true,
|
||||||
}),
|
}),
|
||||||
|
...(type === 'weekly-trending' && {
|
||||||
|
unsubscribedFromWeeklyTrendingEmails: true,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(id).update(update)
|
await firestore.collection('private-users').doc(id).update(update)
|
||||||
|
|
|
@ -88,6 +88,12 @@ export const getPrivateUser = (userId: string) => {
|
||||||
return getDoc<PrivateUser>('private-users', userId)
|
return getDoc<PrivateUser>('private-users', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAllPrivateUsers = async () => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const users = await firestore.collection('private-users').get()
|
||||||
|
return users.docs.map((doc) => doc.data() as PrivateUser)
|
||||||
|
}
|
||||||
|
|
||||||
export const getUserByUsername = async (username: string) => {
|
export const getUserByUsername = async (username: string) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const snap = await firestore
|
const snap = await firestore
|
||||||
|
|
81
functions/src/weekly-markets-emails.ts
Normal file
81
functions/src/weekly-markets-emails.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
|
import { getAllPrivateUsers, getUser, getValues, log } from './utils'
|
||||||
|
import { sendInterestingMarketsEmail } from './emails'
|
||||||
|
import { createRNG, shuffle } from '../../common/util/random'
|
||||||
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
|
||||||
|
export const weeklyMarketsEmails = functions
|
||||||
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
// every Monday at 12pm PT (UTC -07:00)
|
||||||
|
.pubsub.schedule('0 19 * * 1')
|
||||||
|
.timeZone('utc')
|
||||||
|
.onRun(async () => {
|
||||||
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export async function getTrendingContracts() {
|
||||||
|
return await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('isResolved', '==', false)
|
||||||
|
.where('visibility', '==', 'public')
|
||||||
|
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||||
|
// so have to filter for closed contracts separately
|
||||||
|
.orderBy('popularityScore', 'desc')
|
||||||
|
// might as well go big and do a quick filter for closed ones later
|
||||||
|
.limit(500)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
|
const numContractsToSend = 6
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
// get all users that haven't unsubscribed from weekly emails
|
||||||
|
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||||
|
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||||
|
})
|
||||||
|
const trendingContracts = (await getTrendingContracts())
|
||||||
|
.filter(
|
||||||
|
(contract) =>
|
||||||
|
!(
|
||||||
|
contract.question.toLowerCase().includes('trump') &&
|
||||||
|
contract.question.toLowerCase().includes('president')
|
||||||
|
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS
|
||||||
|
)
|
||||||
|
.slice(0, 20)
|
||||||
|
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||||
|
if (!privateUser.email) {
|
||||||
|
log(`No email for ${privateUser.username}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const contractsAvailableToSend = trendingContracts.filter((contract) => {
|
||||||
|
return !contract.uniqueBettorIds?.includes(privateUser.id)
|
||||||
|
})
|
||||||
|
if (contractsAvailableToSend.length < numContractsToSend) {
|
||||||
|
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// choose random subset of contracts to send to user
|
||||||
|
const contractsToSend = chooseRandomSubset(
|
||||||
|
contractsAvailableToSend,
|
||||||
|
numContractsToSend
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
if (!user) continue
|
||||||
|
|
||||||
|
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||||
|
shuffle(contracts, createRNG(seed))
|
||||||
|
return contracts.slice(0, count)
|
||||||
|
}
|
|
@ -1,61 +1,7 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { Challenge } from 'common/challenge'
|
import { Challenge } from 'common/challenge'
|
||||||
|
import { buildCardUrl, OgCardProps } from 'common/contract-details'
|
||||||
export type OgCardProps = {
|
|
||||||
question: string
|
|
||||||
probability?: string
|
|
||||||
metadata: string
|
|
||||||
creatorName: string
|
|
||||||
creatorUsername: string
|
|
||||||
creatorAvatarUrl?: string
|
|
||||||
numericValue?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
|
||||||
const {
|
|
||||||
creatorAmount,
|
|
||||||
acceptances,
|
|
||||||
acceptorAmount,
|
|
||||||
creatorOutcome,
|
|
||||||
acceptorOutcome,
|
|
||||||
} = challenge || {}
|
|
||||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
|
||||||
|
|
||||||
const probabilityParam =
|
|
||||||
props.probability === undefined
|
|
||||||
? ''
|
|
||||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
|
||||||
|
|
||||||
const numericValueParam =
|
|
||||||
props.numericValue === undefined
|
|
||||||
? ''
|
|
||||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
|
||||||
|
|
||||||
const creatorAvatarUrlParam =
|
|
||||||
props.creatorAvatarUrl === undefined
|
|
||||||
? ''
|
|
||||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
|
||||||
|
|
||||||
const challengeUrlParams = challenge
|
|
||||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
|
||||||
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
|
|
||||||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
// URL encode each of the props, then add them as query params
|
|
||||||
return (
|
|
||||||
`https://manifold-og-image.vercel.app/m.png` +
|
|
||||||
`?question=${encodeURIComponent(props.question)}` +
|
|
||||||
probabilityParam +
|
|
||||||
numericValueParam +
|
|
||||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
|
||||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
|
||||||
creatorAvatarUrlParam +
|
|
||||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
|
||||||
challengeUrlParams
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SEO(props: {
|
export function SEO(props: {
|
||||||
title: string
|
title: string
|
||||||
|
|
|
@ -3,7 +3,6 @@ import React from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Spacer } from './layout/spacer'
|
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
|
@ -37,7 +36,7 @@ export function AmountInput(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group">
|
<label className="input-group mb-4">
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
<span className="bg-gray-200 text-sm">{label}</span>
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -57,8 +56,6 @@ export function AmountInput(props: {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Spacer h={4} />
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||||
{error === 'Insufficient balance' ? (
|
{error === 'Insufficient balance' ? (
|
||||||
|
@ -115,6 +112,8 @@ export function BuyAmountInput(props: {
|
||||||
} else {
|
} else {
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setError(undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Router from 'next/router'
|
import Router from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { MouseEvent, useState } from 'react'
|
import { MouseEvent, useEffect, useState } from 'react'
|
||||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
export function Avatar(props: {
|
export function Avatar(props: {
|
||||||
|
@ -12,6 +12,7 @@ export function Avatar(props: {
|
||||||
}) {
|
}) {
|
||||||
const { username, noLink, size, className } = props
|
const { username, noLink, size, className } = props
|
||||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||||
|
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||||
|
|
||||||
const onClick =
|
const onClick =
|
||||||
|
|
|
@ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
|
|
||||||
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
/** Button that opens BetPanel in a new modal */
|
||||||
export default function BetRow(props: {
|
export default function BetButton(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
btnClassName?: string
|
btnClassName?: string
|
127
web/components/bet-inline.tsx
Normal file
127
web/components/bet-inline.tsx
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
import { getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||||
|
import { APIError } from 'web/lib/firebase/api'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useMutation } from 'react-query'
|
||||||
|
import { placeBet } from 'web/lib/firebase/api'
|
||||||
|
import { BuyAmountInput } from './amount-input'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { SignUpPrompt } from './sign-up-prompt'
|
||||||
|
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
|
// adapted from bet-panel.ts
|
||||||
|
export function BetInline(props: {
|
||||||
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
className?: string
|
||||||
|
setProbAfter: (probAfter: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { contract, className, setProbAfter, onClose } = props
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
|
||||||
|
const [amount, setAmount] = useState<number>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
|
|
||||||
|
const { newPool, newP } = getBinaryCpmmBetInfo(
|
||||||
|
outcome ?? 'YES',
|
||||||
|
amount ?? 0,
|
||||||
|
contract,
|
||||||
|
undefined,
|
||||||
|
unfilledBets
|
||||||
|
)
|
||||||
|
const resultProb = getCpmmProbability(newPool, newP)
|
||||||
|
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
||||||
|
|
||||||
|
const submitBet = useMutation(
|
||||||
|
() => placeBet({ outcome, amount, contractId: contract.id }),
|
||||||
|
{
|
||||||
|
onError: (e) =>
|
||||||
|
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
|
||||||
|
onSuccess: () => {
|
||||||
|
track('bet', {
|
||||||
|
location: 'embed',
|
||||||
|
outcomeType: contract.outcomeType,
|
||||||
|
slug: contract.slug,
|
||||||
|
contractId: contract.id,
|
||||||
|
amount,
|
||||||
|
outcome,
|
||||||
|
isLimitOrder: false,
|
||||||
|
})
|
||||||
|
setAmount(undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// reset error / success state on user change
|
||||||
|
useEffect(() => {
|
||||||
|
amount && submitBet.reset()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [outcome, amount])
|
||||||
|
|
||||||
|
const tooFewFunds = error === 'Insufficient balance'
|
||||||
|
|
||||||
|
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={clsx('items-center', className)}>
|
||||||
|
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
||||||
|
<div className="text-xl">Bet</div>
|
||||||
|
<YesNoSelector
|
||||||
|
className="space-x-0"
|
||||||
|
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||||
|
selected={outcome}
|
||||||
|
onSelect={setOutcome}
|
||||||
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
|
/>
|
||||||
|
<BuyAmountInput
|
||||||
|
className="-mb-4"
|
||||||
|
inputClassName={clsx(
|
||||||
|
'input-sm w-20 !text-base',
|
||||||
|
error && 'input-error'
|
||||||
|
)}
|
||||||
|
amount={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
error="" // handle error ourselves
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
{user && (
|
||||||
|
<Button
|
||||||
|
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
|
||||||
|
size="xs"
|
||||||
|
disabled={betDisabled}
|
||||||
|
onClick={() => submitBet.mutate()}
|
||||||
|
>
|
||||||
|
{submitBet.isLoading
|
||||||
|
? 'Submitting'
|
||||||
|
: submitBet.isSuccess
|
||||||
|
? 'Success!'
|
||||||
|
: 'Submit'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<SignUpPrompt size="xs" />
|
||||||
|
<button onClick={onClose}>
|
||||||
|
<XIcon className="ml-1 h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
{error && (
|
||||||
|
<div className="text-error my-1 text-sm">
|
||||||
|
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
|
@ -534,9 +534,8 @@ export function ContractBetsTable(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
isYourBets: boolean
|
isYourBets: boolean
|
||||||
className?: string
|
|
||||||
}) {
|
}) {
|
||||||
const { contract, className, isYourBets } = props
|
const { contract, isYourBets } = props
|
||||||
|
|
||||||
const bets = sortBy(
|
const bets = sortBy(
|
||||||
props.bets.filter((b) => !b.isAnte && b.amount !== 0),
|
props.bets.filter((b) => !b.isAnte && b.amount !== 0),
|
||||||
|
@ -568,7 +567,7 @@ export function ContractBetsTable(props: {
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('overflow-x-auto', className)}>
|
<div className="overflow-x-auto">
|
||||||
{amountRedeemed > 0 && (
|
{amountRedeemed > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="pl-2 text-sm text-gray-500">
|
<div className="pl-2 text-sm text-gray-500">
|
||||||
|
@ -771,7 +770,7 @@ function SellButton(props: {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-4 text-2xl">
|
<div className="mb-4 text-xl">
|
||||||
Sell {formatWithCommas(shares)} shares of{' '}
|
Sell {formatWithCommas(shares)} shares of{' '}
|
||||||
<OutcomeLabel outcome={outcome} contract={contract} truncate="long" />{' '}
|
<OutcomeLabel outcome={outcome} contract={contract} truncate="long" />{' '}
|
||||||
for {formatMoney(saleAmount)}?
|
for {formatMoney(saleAmount)}?
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
|
export type ColorType =
|
||||||
|
| 'green'
|
||||||
|
| 'red'
|
||||||
|
| 'blue'
|
||||||
|
| 'indigo'
|
||||||
|
| 'yellow'
|
||||||
|
| 'gray'
|
||||||
|
| 'gradient'
|
||||||
|
| 'gray-white'
|
||||||
|
|
||||||
export function Button(props: {
|
export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
size?: SizeType
|
||||||
color?:
|
color?: ColorType
|
||||||
| 'green'
|
|
||||||
| 'red'
|
|
||||||
| 'blue'
|
|
||||||
| 'indigo'
|
|
||||||
| 'yellow'
|
|
||||||
| 'gray'
|
|
||||||
| 'gradient'
|
|
||||||
| 'gray-white'
|
|
||||||
type?: 'button' | 'reset' | 'submit'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||||
import { BinaryContract } from 'common/contract'
|
import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { NoLabel, YesLabel } from '../outcome-label'
|
import { NoLabel, YesLabel } from '../outcome-label'
|
||||||
|
@ -19,24 +19,32 @@ import { QRCode } from '../qr-code'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { AmountInput } from '../amount-input'
|
import { AmountInput } from '../amount-input'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
|
import { createMarket } from 'web/lib/firebase/api'
|
||||||
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
import { FIXED_ANTE } from 'common/antes'
|
||||||
|
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 { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
type challengeInfo = {
|
type challengeInfo = {
|
||||||
amount: number
|
amount: number
|
||||||
expiresTime: number | null
|
expiresTime: number | null
|
||||||
message: string
|
|
||||||
outcome: 'YES' | 'NO' | number
|
outcome: 'YES' | 'NO' | number
|
||||||
acceptorAmount: number
|
acceptorAmount: number
|
||||||
|
question: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateChallengeModal(props: {
|
export function CreateChallengeModal(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
contract: BinaryContract
|
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
|
contract?: BinaryContract
|
||||||
}) {
|
}) {
|
||||||
const { user, contract, isOpen, setOpen } = props
|
const { user, contract, isOpen, setOpen } = props
|
||||||
const [challengeSlug, setChallengeSlug] = useState('')
|
const [challengeSlug, setChallengeSlug] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { editor } = useTextEditor({ placeholder: '' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
|
@ -46,24 +54,42 @@ export function CreateChallengeModal(props: {
|
||||||
<CreateChallengeForm
|
<CreateChallengeForm
|
||||||
user={user}
|
user={user}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
loading={loading}
|
||||||
onCreate={async (newChallenge) => {
|
onCreate={async (newChallenge) => {
|
||||||
const challenge = await createChallenge({
|
setLoading(true)
|
||||||
creator: user,
|
try {
|
||||||
creatorAmount: newChallenge.amount,
|
const challengeContract = contract
|
||||||
expiresTime: newChallenge.expiresTime,
|
? contract
|
||||||
message: newChallenge.message,
|
: await createMarket(
|
||||||
acceptorAmount: newChallenge.acceptorAmount,
|
removeUndefinedProps({
|
||||||
outcome: newChallenge.outcome,
|
question: newChallenge.question,
|
||||||
contract: contract,
|
outcomeType: 'BINARY',
|
||||||
})
|
initialProb: 50,
|
||||||
if (challenge) {
|
description: editor?.getJSON(),
|
||||||
setChallengeSlug(getChallengeUrl(challenge))
|
ante: FIXED_ANTE,
|
||||||
track('challenge created', {
|
closeTime: dayjs().add(30, 'day').valueOf(),
|
||||||
creator: user.username,
|
})
|
||||||
amount: newChallenge.amount,
|
)
|
||||||
contractId: contract.id,
|
const challenge = await createChallenge({
|
||||||
|
creator: user,
|
||||||
|
creatorAmount: newChallenge.amount,
|
||||||
|
expiresTime: newChallenge.expiresTime,
|
||||||
|
acceptorAmount: newChallenge.acceptorAmount,
|
||||||
|
outcome: newChallenge.outcome,
|
||||||
|
contract: challengeContract as BinaryContract,
|
||||||
})
|
})
|
||||||
|
if (challenge) {
|
||||||
|
setChallengeSlug(getChallengeUrl(challenge))
|
||||||
|
track('challenge created', {
|
||||||
|
creator: user.username,
|
||||||
|
amount: newChallenge.amount,
|
||||||
|
contractId: challengeContract.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("couldn't create market/challenge:", e)
|
||||||
}
|
}
|
||||||
|
setLoading(false)
|
||||||
}}
|
}}
|
||||||
challengeSlug={challengeSlug}
|
challengeSlug={challengeSlug}
|
||||||
/>
|
/>
|
||||||
|
@ -75,25 +101,24 @@ export function CreateChallengeModal(props: {
|
||||||
|
|
||||||
function CreateChallengeForm(props: {
|
function CreateChallengeForm(props: {
|
||||||
user: User
|
user: User
|
||||||
contract: BinaryContract
|
|
||||||
onCreate: (m: challengeInfo) => Promise<void>
|
onCreate: (m: challengeInfo) => Promise<void>
|
||||||
challengeSlug: string
|
challengeSlug: string
|
||||||
|
loading: boolean
|
||||||
|
contract?: BinaryContract
|
||||||
}) {
|
}) {
|
||||||
const { user, onCreate, contract, challengeSlug } = props
|
const { user, onCreate, contract, challengeSlug, loading } = props
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||||
const defaultExpire = 'week'
|
const defaultExpire = 'week'
|
||||||
|
|
||||||
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
|
|
||||||
|
|
||||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||||
outcome: 'YES',
|
outcome: 'YES',
|
||||||
amount: 100,
|
amount: 100,
|
||||||
acceptorAmount: 100,
|
acceptorAmount: 100,
|
||||||
message: defaultMessage,
|
question: contract ? contract.question : '',
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError('')
|
setError('')
|
||||||
|
@ -106,7 +131,15 @@ function CreateChallengeForm(props: {
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (user.balance < challengeInfo.amount) {
|
if (user.balance < challengeInfo.amount) {
|
||||||
setError('You do not have enough mana to create this challenge')
|
setError("You don't have enough mana to create this challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) {
|
||||||
|
setError(
|
||||||
|
`You don't have enough mana to create this challenge and market. You need ${formatMoney(
|
||||||
|
FIXED_ANTE + challengeInfo.amount
|
||||||
|
)}`
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
|
@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
Challenge a friend to bet on{' '}
|
Challenge a friend to bet on{' '}
|
||||||
<span className="underline">{contract.question}</span>
|
{contract ? (
|
||||||
|
<span className="underline">{contract.question}</span>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g. Will a Democrat be the next president?"
|
||||||
|
className="input input-bordered mt-1 w-full resize-none"
|
||||||
|
autoFocus={true}
|
||||||
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
|
value={challengeInfo.question}
|
||||||
|
onChange={(e) =>
|
||||||
|
setChallengeInfo({
|
||||||
|
...challengeInfo,
|
||||||
|
question: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||||
|
@ -187,22 +236,23 @@ function CreateChallengeForm(props: {
|
||||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{contract && (
|
||||||
size="2xs"
|
<Button
|
||||||
color="gray"
|
size="2xs"
|
||||||
onClick={() => {
|
color="gray"
|
||||||
setEditingAcceptorAmount(true)
|
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>
|
|
||||||
|
|
||||||
|
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">
|
<div className="mt-8">
|
||||||
If the challenge is accepted, whoever is right will earn{' '}
|
If the challenge is accepted, whoever is right will earn{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
|
@ -210,7 +260,18 @@ function CreateChallengeForm(props: {
|
||||||
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
||||||
)}
|
)}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
in total.
|
in total.{' '}
|
||||||
|
<span>
|
||||||
|
{!contract && (
|
||||||
|
<span>
|
||||||
|
Because there's no market yet, you'll be charged
|
||||||
|
<span className={'mx-1 font-semibold'}>
|
||||||
|
{formatMoney(FIXED_ANTE)}
|
||||||
|
</span>
|
||||||
|
to create it.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row className="mt-8 items-center">
|
<Row className="mt-8 items-center">
|
||||||
|
@ -218,10 +279,8 @@ function CreateChallengeForm(props: {
|
||||||
type="submit"
|
type="submit"
|
||||||
color={'gradient'}
|
color={'gradient'}
|
||||||
size="xl"
|
size="xl"
|
||||||
className={clsx(
|
disabled={isCreating || challengeInfo.question === ''}
|
||||||
'whitespace-nowrap drop-shadow-md',
|
className={clsx('whitespace-nowrap drop-shadow-md')}
|
||||||
isCreating ? 'disabled' : ''
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Create challenge bet
|
Create challenge bet
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -229,7 +288,12 @@ function CreateChallengeForm(props: {
|
||||||
<Row className={'text-error'}>{error} </Row>
|
<Row className={'text-error'}>{error} </Row>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{finishedCreating && (
|
{loading && (
|
||||||
|
<Col className={'h-56 w-full items-center justify-center'}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{finishedCreating && !loading && (
|
||||||
<>
|
<>
|
||||||
<Title className="!my-0" text="Challenge Created!" />
|
<Title className="!my-0" text="Challenge Created!" />
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Comment } from 'common/comment'
|
import { Comment, ContractComment } from 'common/comment'
|
||||||
import { groupConsecutive } from 'common/util/array'
|
import { groupConsecutive } from 'common/util/array'
|
||||||
import { getUsersComments } from 'web/lib/firebase/comments'
|
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
|
@ -16,12 +16,6 @@ import { LoadingIndicator } from './loading-indicator'
|
||||||
|
|
||||||
const COMMENTS_PER_PAGE = 50
|
const COMMENTS_PER_PAGE = 50
|
||||||
|
|
||||||
type ContractComment = Comment & {
|
|
||||||
contractId: string
|
|
||||||
contractSlug: string
|
|
||||||
contractQuestion: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function contractPath(slug: string) {
|
function contractPath(slug: string) {
|
||||||
// by convention this includes the contract creator username, but we don't
|
// by convention this includes the contract creator username, but we don't
|
||||||
// have that handy, so we just put /market/
|
// have that handy, so we just put /market/
|
||||||
|
@ -38,7 +32,9 @@ export function UserCommentsList(props: { user: User }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUsersComments(user.id).then((cs) => {
|
getUsersComments(user.id).then((cs) => {
|
||||||
// we don't show comments in groups here atm, just comments on contracts
|
// we don't show comments in groups here atm, just comments on contracts
|
||||||
setComments(cs.filter((c) => c.contractId) as ContractComment[])
|
setComments(
|
||||||
|
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, [user.id])
|
}, [user.id])
|
||||||
|
|
||||||
|
|
|
@ -83,7 +83,6 @@ export function ContractSearch(props: {
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
hideOrderSelector?: boolean
|
hideOrderSelector?: boolean
|
||||||
overrideGridClassName?: string
|
|
||||||
cardHideOptions?: {
|
cardHideOptions?: {
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
|
@ -91,6 +90,7 @@ export function ContractSearch(props: {
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
useQuerySortLocalStorage?: boolean
|
useQuerySortLocalStorage?: boolean
|
||||||
useQuerySortUrlParams?: boolean
|
useQuerySortUrlParams?: boolean
|
||||||
|
isWholePage?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
|
@ -98,13 +98,13 @@ export function ContractSearch(props: {
|
||||||
defaultFilter,
|
defaultFilter,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
cardHideOptions,
|
cardHideOptions,
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
useQuerySortLocalStorage,
|
useQuerySortLocalStorage,
|
||||||
useQuerySortUrlParams,
|
useQuerySortUrlParams,
|
||||||
|
isWholePage,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [numPages, setNumPages] = useState(1)
|
const [numPages, setNumPages] = useState(1)
|
||||||
|
@ -139,7 +139,7 @@ export function ContractSearch(props: {
|
||||||
setNumPages(results.nbPages)
|
setNumPages(results.nbPages)
|
||||||
if (freshQuery) {
|
if (freshQuery) {
|
||||||
setPages([newPage])
|
setPages([newPage])
|
||||||
window.scrollTo(0, 0)
|
if (isWholePage) window.scrollTo(0, 0)
|
||||||
} else {
|
} else {
|
||||||
setPages((pages) => [...pages, newPage])
|
setPages((pages) => [...pages, newPage])
|
||||||
}
|
}
|
||||||
|
@ -181,7 +181,6 @@ export function ContractSearch(props: {
|
||||||
loadMore={performQuery}
|
loadMore={performQuery}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
overrideGridClassName={overrideGridClassName}
|
|
||||||
highlightOptions={highlightOptions}
|
highlightOptions={highlightOptions}
|
||||||
cardHideOptions={cardHideOptions}
|
cardHideOptions={cardHideOptions}
|
||||||
/>
|
/>
|
||||||
|
@ -256,9 +255,12 @@ function ContractSearchControls(props: {
|
||||||
? additionalFilters
|
? additionalFilters
|
||||||
: [
|
: [
|
||||||
...additionalFilters,
|
...additionalFilters,
|
||||||
|
additionalFilter ? '' : 'visibility:public',
|
||||||
|
|
||||||
filter === 'open' ? 'isResolved:false' : '',
|
filter === 'open' ? 'isResolved:false' : '',
|
||||||
filter === 'closed' ? 'isResolved:false' : '',
|
filter === 'closed' ? 'isResolved:false' : '',
|
||||||
filter === 'resolved' ? 'isResolved:true' : '',
|
filter === 'resolved' ? 'isResolved:true' : '',
|
||||||
|
|
||||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||||
? `groupLinks.slug:${pillFilter}`
|
? `groupLinks.slug:${pillFilter}`
|
||||||
: '',
|
: '',
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
|
||||||
import { richTextToString } from 'common/util/parse'
|
|
||||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
|
||||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
|
||||||
import { getProbability } from 'common/calculate'
|
|
||||||
|
|
||||||
export const getOpenGraphProps = (contract: Contract) => {
|
|
||||||
const {
|
|
||||||
resolution,
|
|
||||||
question,
|
|
||||||
creatorName,
|
|
||||||
creatorUsername,
|
|
||||||
outcomeType,
|
|
||||||
creatorAvatarUrl,
|
|
||||||
description: desc,
|
|
||||||
} = contract
|
|
||||||
const probPercent =
|
|
||||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
|
||||||
|
|
||||||
const numericValue =
|
|
||||||
outcomeType === 'PSEUDO_NUMERIC'
|
|
||||||
? getFormattedMappedValue(contract)(getProbability(contract))
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
|
||||||
|
|
||||||
const description = resolution
|
|
||||||
? `Resolved ${resolution}. ${stringDesc}`
|
|
||||||
: probPercent
|
|
||||||
? `${probPercent} chance. ${stringDesc}`
|
|
||||||
: stringDesc
|
|
||||||
|
|
||||||
return {
|
|
||||||
question,
|
|
||||||
probability: probPercent,
|
|
||||||
metadata: contractTextDetails(contract),
|
|
||||||
creatorName,
|
|
||||||
creatorUsername,
|
|
||||||
creatorAvatarUrl,
|
|
||||||
description,
|
|
||||||
numericValue,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -120,7 +120,7 @@ export function ContractCard(props: {
|
||||||
truncate={'long'}
|
truncate={'long'}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
<FreeResponseTopAnswer contract={contract} />
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
{showQuickBet ? (
|
{showQuickBet ? (
|
||||||
|
@ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract
|
||||||
large?: boolean
|
large?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
probAfter?: number // 0 to 1
|
||||||
}) {
|
}) {
|
||||||
const { contract, large, className } = props
|
const { contract, large, className, probAfter } = props
|
||||||
const { resolution } = contract
|
const { resolution } = contract
|
||||||
const textColor = `text-${getColor(contract)}`
|
const textColor = `text-${getColor(contract)}`
|
||||||
|
|
||||||
|
const before = getBinaryProbPercent(contract)
|
||||||
|
const after = probAfter && formatPercent(probAfter)
|
||||||
|
const probChanged = before !== after
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
||||||
{resolution ? (
|
{resolution ? (
|
||||||
|
@ -206,7 +211,14 @@ export function BinaryResolutionOrChance(props: {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
|
{probAfter && probChanged ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 line-through">{before}</span>
|
||||||
|
<span className={textColor}>{after}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={textColor}>{before}</div>
|
||||||
|
)}
|
||||||
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
|
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
|
||||||
chance
|
chance
|
||||||
</div>
|
</div>
|
||||||
|
@ -218,10 +230,9 @@ export function BinaryResolutionOrChance(props: {
|
||||||
|
|
||||||
function FreeResponseTopAnswer(props: {
|
function FreeResponseTopAnswer(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
truncate: 'short' | 'long' | 'none'
|
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, truncate } = props
|
const { contract } = props
|
||||||
|
|
||||||
const topAnswer = getTopAnswer(contract)
|
const topAnswer = getTopAnswer(contract)
|
||||||
|
|
||||||
|
@ -229,7 +240,7 @@ function FreeResponseTopAnswer(props: {
|
||||||
<AnswerLabel
|
<AnswerLabel
|
||||||
className="!text-gray-600"
|
className="!text-gray-600"
|
||||||
answer={topAnswer}
|
answer={topAnswer}
|
||||||
truncate={truncate}
|
truncate="long"
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,7 @@ import {
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { UserLink } from '../user-page'
|
import { UserLink } from '../user-page'
|
||||||
import {
|
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||||
Contract,
|
|
||||||
contractMetrics,
|
|
||||||
updateContract,
|
|
||||||
} from 'web/lib/firebase/contracts'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import { fromNow } from 'web/lib/util/time'
|
||||||
|
@ -35,6 +31,7 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { insertContent } from '../editor/utils'
|
import { insertContent } from '../editor/utils'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { contractMetrics } from 'common/contract-details'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -245,25 +242,6 @@ export function ContractDetails(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// String version of the above, to send to the OpenGraph image generator
|
|
||||||
export function contractTextDetails(contract: Contract) {
|
|
||||||
const { closeTime, tags } = contract
|
|
||||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
|
||||||
|
|
||||||
const hashtags = tags.map((tag) => `#${tag}`)
|
|
||||||
|
|
||||||
return (
|
|
||||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
|
||||||
(closeTime
|
|
||||||
? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs(
|
|
||||||
closeTime
|
|
||||||
).format('MMM D, h:mma')}`
|
|
||||||
: '') +
|
|
||||||
` • ${volumeLabel}` +
|
|
||||||
(hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditableCloseDate(props: {
|
function EditableCloseDate(props: {
|
||||||
closeTime: number
|
closeTime: number
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
|
|
@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Payout</td>
|
<td>Payout</td>
|
||||||
<td>
|
<td className="flex gap-1">
|
||||||
{mechanism === 'cpmm-1' ? (
|
{mechanism === 'cpmm-1' ? (
|
||||||
<>
|
<>
|
||||||
Fixed{' '}
|
Fixed{' '}
|
||||||
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
|
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<>
|
||||||
Parimutuel{' '}
|
Parimutuel{' '}
|
||||||
<InfoTooltip text="Each share is a fraction of the pool. " />
|
<InfoTooltip text="Each share is a fraction of the pool. " />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { resolvedPayout } from 'common/calculate'
|
import { resolvedPayout } from 'common/calculate'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -65,7 +65,7 @@ export function ContractLeaderboard(props: {
|
||||||
export function ContractTopTrades(props: {
|
export function ContractTopTrades(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, comments, tips } = props
|
const { contract, bets, comments, tips } = props
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
PseudoNumericResolutionOrExpectation,
|
PseudoNumericResolutionOrExpectation,
|
||||||
} from './contract-card'
|
} from './contract-card'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import BetRow from '../bet-row'
|
import BetButton from '../bet-button'
|
||||||
import { AnswersGraph } from '../answers/answers-graph'
|
import { AnswersGraph } from '../answers/answers-graph'
|
||||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||||
import { ContractDescription } from './contract-description'
|
import { ContractDescription } from './contract-description'
|
||||||
|
@ -73,18 +73,18 @@ export const ContractOverview = (props: {
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
|
|
||||||
{tradingAllowed(contract) && (
|
{tradingAllowed(contract) && (
|
||||||
<BetRow contract={contract as CPMMBinaryContract} />
|
<BetButton contract={contract as CPMMBinaryContract} />
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
) : isPseudoNumeric ? (
|
) : isPseudoNumeric ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||||
</Row>
|
</Row>
|
||||||
) : isPseudoNumeric ? (
|
) : isPseudoNumeric ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
(outcomeType === 'FREE_RESPONSE' ||
|
(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { Comment } from 'web/lib/firebase/comments'
|
import { ContractComment } from 'common/comment'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { ContractActivity } from '../feed/contract-activity'
|
import { ContractActivity } from '../feed/contract-activity'
|
||||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||||
|
@ -15,7 +15,7 @@ export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, bets, tips } = props
|
const { contract, user, bets, tips } = props
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { useCallback } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { LoadingIndicator } from '../loading-indicator'
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
import { VisibilityObserver } from '../visibility-observer'
|
import { VisibilityObserver } from '../visibility-observer'
|
||||||
|
import Masonry from 'react-masonry-css'
|
||||||
|
|
||||||
export type ContractHighlightOptions = {
|
export type ContractHighlightOptions = {
|
||||||
contractIds?: string[]
|
contractIds?: string[]
|
||||||
|
@ -20,7 +21,6 @@ export function ContractsGrid(props: {
|
||||||
loadMore?: () => void
|
loadMore?: () => void
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
overrideGridClassName?: string
|
|
||||||
cardHideOptions?: {
|
cardHideOptions?: {
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
|
@ -32,7 +32,6 @@ export function ContractsGrid(props: {
|
||||||
showTime,
|
showTime,
|
||||||
loadMore,
|
loadMore,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
|
||||||
cardHideOptions,
|
cardHideOptions,
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
} = props
|
} = props
|
||||||
|
@ -64,12 +63,11 @@ export function ContractsGrid(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-8">
|
<Col className="gap-8">
|
||||||
<ul
|
<Masonry
|
||||||
className={clsx(
|
// Show only 1 column on tailwind's md breakpoint (768px)
|
||||||
overrideGridClassName
|
breakpointCols={{ default: 2, 768: 1 }}
|
||||||
? overrideGridClassName
|
className="-ml-4 flex w-auto"
|
||||||
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
|
columnClassName="pl-4 bg-clip-padding"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{contracts.map((contract) => (
|
{contracts.map((contract) => (
|
||||||
<ContractCard
|
<ContractCard
|
||||||
|
@ -81,14 +79,13 @@ export function ContractsGrid(props: {
|
||||||
}
|
}
|
||||||
hideQuickBet={hideQuickBet}
|
hideQuickBet={hideQuickBet}
|
||||||
hideGroupLink={hideGroupLink}
|
hideGroupLink={hideGroupLink}
|
||||||
className={
|
className={clsx(
|
||||||
contractIds?.includes(contract.id)
|
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||||
? highlightClassName
|
contractIds?.includes(contract.id) && highlightClassName
|
||||||
: undefined
|
)}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Masonry>
|
||||||
<VisibilityObserver
|
<VisibilityObserver
|
||||||
onVisibilityUpdated={onVisibilityUpdated}
|
onVisibilityUpdated={onVisibilityUpdated}
|
||||||
className="relative -top-96 h-1"
|
className="relative -top-96 h-1"
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { placeBet } from 'web/lib/firebase/api'
|
import { placeBet } from 'web/lib/firebase/api'
|
||||||
import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||||
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
|
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
|
||||||
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
|
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
|
@ -34,6 +34,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
|
import { getBinaryProb } from 'common/contract-details'
|
||||||
|
|
||||||
const BET_SIZE = 10
|
const BET_SIZE = 10
|
||||||
|
|
||||||
|
|
|
@ -65,9 +65,6 @@ export function MarketModal(props: {
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
hideOrderSelector
|
hideOrderSelector
|
||||||
onContractClick={addContract}
|
onContractClick={addContract}
|
||||||
overrideGridClassName={
|
|
||||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
|
||||||
}
|
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||||
highlightOptions={{
|
highlightOptions={{
|
||||||
contractIds: contracts.map((c) => c.id),
|
contractIds: contracts.map((c) => c.id),
|
||||||
|
|
|
@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
||||||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||||
)}
|
)}
|
||||||
onClick={() => submitUser(i)}
|
onClick={() => submitUser(i)}
|
||||||
|
key={user.id}
|
||||||
>
|
>
|
||||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||||
{user.username}
|
{user.username}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash'
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { Comment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { Contract, FreeResponseContract } from 'common/contract'
|
import { Contract, FreeResponseContract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
@ -28,7 +28,7 @@ type BaseActivityItem = {
|
||||||
export type CommentInputItem = BaseActivityItem & {
|
export type CommentInputItem = BaseActivityItem & {
|
||||||
type: 'commentInput'
|
type: 'commentInput'
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
commentsByCurrentUser: Comment[]
|
commentsByCurrentUser: ContractComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DescriptionItem = BaseActivityItem & {
|
export type DescriptionItem = BaseActivityItem & {
|
||||||
|
@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & {
|
||||||
|
|
||||||
export type CommentThreadItem = BaseActivityItem & {
|
export type CommentThreadItem = BaseActivityItem & {
|
||||||
type: 'commentThread'
|
type: 'commentThread'
|
||||||
parentComment: Comment
|
parentComment: ContractComment
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
|
||||||
type: 'answergroup'
|
type: 'answergroup'
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
answer: Answer
|
answer: Answer
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}
|
}
|
||||||
|
@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & {
|
||||||
function getAnswerAndCommentInputGroups(
|
function getAnswerAndCommentInputGroups(
|
||||||
contract: FreeResponseContract,
|
contract: FreeResponseContract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: ContractComment[],
|
||||||
tips: CommentTipMap,
|
tips: CommentTipMap,
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
) {
|
) {
|
||||||
|
@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups(
|
||||||
|
|
||||||
function getCommentThreads(
|
function getCommentThreads(
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: ContractComment[],
|
||||||
tips: CommentTipMap,
|
tips: CommentTipMap,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) {
|
) {
|
||||||
|
@ -135,7 +135,7 @@ function getCommentThreads(
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
|
||||||
return (
|
return (
|
||||||
comment.answerOutcome === undefined &&
|
comment.answerOutcome === undefined &&
|
||||||
(contract.outcomeType === 'FREE_RESPONSE'
|
(contract.outcomeType === 'FREE_RESPONSE'
|
||||||
|
@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
||||||
export function getSpecificContractActivityItems(
|
export function getSpecificContractActivityItems(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: ContractComment[],
|
||||||
liquidityProvisions: LiquidityProvision[],
|
liquidityProvisions: LiquidityProvision[],
|
||||||
tips: CommentTipMap,
|
tips: CommentTipMap,
|
||||||
user: User | null | undefined,
|
user: User | null | undefined,
|
||||||
|
|
|
@ -1,167 +0,0 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
|
||||||
import { union, difference } from 'lodash'
|
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
|
||||||
import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories'
|
|
||||||
import { Modal } from '../layout/modal'
|
|
||||||
import { Col } from '../layout/col'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { updateUser, User } from 'web/lib/firebase/users'
|
|
||||||
import { Checkbox } from '../checkbox'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
|
||||||
|
|
||||||
export function CategorySelector(props: {
|
|
||||||
category: string
|
|
||||||
setCategory: (category: string) => void
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { className, category, setCategory } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row
|
|
||||||
className={clsx(
|
|
||||||
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
<CategoryButton
|
|
||||||
key="all"
|
|
||||||
category="All"
|
|
||||||
isFollowed={category === 'all'}
|
|
||||||
toggle={() => {
|
|
||||||
setCategory('all')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CategoryButton
|
|
||||||
key="following"
|
|
||||||
category="Following"
|
|
||||||
isFollowed={category === 'following'}
|
|
||||||
toggle={() => {
|
|
||||||
setCategory('following')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{CATEGORY_LIST.map((cat) => (
|
|
||||||
<CategoryButton
|
|
||||||
key={cat}
|
|
||||||
category={CATEGORIES[cat as category].split(' ')[0]}
|
|
||||||
isFollowed={cat === category}
|
|
||||||
toggle={() => {
|
|
||||||
setCategory(cat)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CategoryButton(props: {
|
|
||||||
category: string
|
|
||||||
isFollowed: boolean
|
|
||||||
toggle: () => void
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { toggle, category, isFollowed, className } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
|
||||||
'cursor-pointer select-none',
|
|
||||||
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
|
||||||
)}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-500">{category}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditCategoriesButton(props: {
|
|
||||||
user: User
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { user, className } = props
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(true)
|
|
||||||
track('edit categories button')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PencilIcon className="inline h-4 w-4" />
|
|
||||||
Categories
|
|
||||||
<CategorySelectorModal
|
|
||||||
user={user}
|
|
||||||
isOpen={isOpen}
|
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CategorySelectorModal(props: {
|
|
||||||
user: User
|
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (isOpen: boolean) => void
|
|
||||||
}) {
|
|
||||||
const { user, isOpen, setIsOpen } = props
|
|
||||||
const followedCategories =
|
|
||||||
user?.followedCategories === undefined
|
|
||||||
? CATEGORY_LIST
|
|
||||||
: user.followedCategories
|
|
||||||
|
|
||||||
const selectAll =
|
|
||||||
user.followedCategories === undefined ||
|
|
||||||
followedCategories.length < CATEGORY_LIST.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
|
||||||
<Col className="rounded bg-white p-6">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline mb-4 self-start normal-case"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectAll) {
|
|
||||||
updateUser(user.id, {
|
|
||||||
followedCategories: CATEGORY_LIST,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
updateUser(user.id, {
|
|
||||||
followedCategories: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select {selectAll ? 'all' : 'none'}
|
|
||||||
</button>
|
|
||||||
<Col className="grid w-full grid-cols-2 gap-4">
|
|
||||||
{CATEGORY_LIST.map((cat) => (
|
|
||||||
<Checkbox
|
|
||||||
className="col-span-1"
|
|
||||||
key={cat}
|
|
||||||
label={CATEGORIES[cat as category].split(' ')[0]}
|
|
||||||
checked={followedCategories.includes(cat)}
|
|
||||||
toggle={(checked) => {
|
|
||||||
updateUser(user.id, {
|
|
||||||
followedCategories: checked
|
|
||||||
? difference(followedCategories, [cat])
|
|
||||||
: union([cat], followedCategories),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Contract } from 'web/lib/firebase/contracts'
|
import { Contract } from 'web/lib/firebase/contracts'
|
||||||
import { Comment } from 'web/lib/firebase/comments'
|
import { ContractComment } from 'common/comment'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { useBets } from 'web/hooks/use-bets'
|
import { useBets } from 'web/hooks/use-bets'
|
||||||
import { getSpecificContractActivityItems } from './activity-items'
|
import { getSpecificContractActivityItems } from './activity-items'
|
||||||
|
@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
export function ContractActivity(props: {
|
export function ContractActivity(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
liquidityProvisions: LiquidityProvision[]
|
liquidityProvisions: LiquidityProvision[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
|
@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
contract: any
|
contract: any
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
answer: Answer
|
answer: Answer
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}) {
|
}) {
|
||||||
|
@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
])
|
])
|
||||||
|
|
||||||
const scrollAndOpenReplyInput = useEvent(
|
const scrollAndOpenReplyInput = useEvent(
|
||||||
(comment?: Comment, answer?: Answer) => {
|
(comment?: ContractComment, answer?: Answer) => {
|
||||||
setReplyToUser(
|
setReplyToUser(
|
||||||
comment
|
comment
|
||||||
? { id: comment.userId, username: comment.userUsername }
|
? { id: comment.userId, username: comment.userUsername }
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react'
|
||||||
|
|
||||||
export function FeedCommentThread(props: {
|
export function FeedCommentThread(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
parentComment: Comment
|
parentComment: ContractComment
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -50,7 +50,7 @@ export function FeedCommentThread(props: {
|
||||||
)
|
)
|
||||||
commentsList.unshift(parentComment)
|
commentsList.unshift(parentComment)
|
||||||
|
|
||||||
function scrollAndOpenReplyInput(comment: Comment) {
|
function scrollAndOpenReplyInput(comment: ContractComment) {
|
||||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||||
setShowReply(true)
|
setShowReply(true)
|
||||||
}
|
}
|
||||||
|
@ -95,10 +95,10 @@ export function FeedCommentThread(props: {
|
||||||
|
|
||||||
export function CommentRepliesList(props: {
|
export function CommentRepliesList(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
commentsList: Comment[]
|
commentsList: ContractComment[]
|
||||||
betsByUserId: Dictionary<Bet[]>
|
betsByUserId: Dictionary<Bet[]>
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
scrollAndOpenReplyInput: (comment: Comment) => void
|
scrollAndOpenReplyInput: (comment: ContractComment) => void
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
treatFirstIndexEqually?: boolean
|
treatFirstIndexEqually?: boolean
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
|
@ -156,12 +156,12 @@ export function CommentRepliesList(props: {
|
||||||
|
|
||||||
export function FeedComment(props: {
|
export function FeedComment(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comment: Comment
|
comment: ContractComment
|
||||||
tips: CommentTips
|
tips: CommentTips
|
||||||
betsBySameUser: Bet[]
|
betsBySameUser: Bet[]
|
||||||
probAtCreatedTime?: number
|
probAtCreatedTime?: number
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
onReplyClick?: (comment: Comment) => void
|
onReplyClick?: (comment: ContractComment) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contract,
|
contract,
|
||||||
|
@ -274,7 +274,7 @@ export function FeedComment(props: {
|
||||||
|
|
||||||
export function getMostRecentCommentableBet(
|
export function getMostRecentCommentableBet(
|
||||||
betsByCurrentUser: Bet[],
|
betsByCurrentUser: Bet[],
|
||||||
commentsByCurrentUser: Comment[],
|
commentsByCurrentUser: ContractComment[],
|
||||||
user?: User | null,
|
user?: User | null,
|
||||||
answerOutcome?: string
|
answerOutcome?: string
|
||||||
) {
|
) {
|
||||||
|
@ -319,7 +319,7 @@ function CommentStatus(props: {
|
||||||
export function CommentInput(props: {
|
export function CommentInput(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
commentsByCurrentUser: Comment[]
|
commentsByCurrentUser: ContractComment[]
|
||||||
replyToUser?: { id: string; username: string }
|
replyToUser?: { id: string; username: string }
|
||||||
// Reply to a free response answer
|
// Reply to a free response answer
|
||||||
parentAnswerOutcome?: string
|
parentAnswerOutcome?: string
|
||||||
|
|
|
@ -11,7 +11,6 @@ import clsx from 'clsx'
|
||||||
import { OutcomeLabel } from '../outcome-label'
|
import { OutcomeLabel } from '../outcome-label'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractMetrics,
|
|
||||||
contractPath,
|
contractPath,
|
||||||
tradingAllowed,
|
tradingAllowed,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
|
@ -19,7 +18,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
|
||||||
import { SiteLink } from '../site-link'
|
import { SiteLink } from '../site-link'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { UserLink } from '../user-page'
|
import { UserLink } from '../user-page'
|
||||||
import BetRow from '../bet-row'
|
import BetButton from '../bet-button'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { ActivityItem } from './activity-items'
|
import { ActivityItem } from './activity-items'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
@ -38,6 +37,7 @@ import { FeedLiquidity } from './feed-liquidity'
|
||||||
import { SignUpPrompt } from '../sign-up-prompt'
|
import { SignUpPrompt } from '../sign-up-prompt'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||||
|
import { contractMetrics } from 'common/contract-details'
|
||||||
|
|
||||||
export function FeedItems(props: {
|
export function FeedItems(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -76,7 +76,7 @@ export function FeedItems(props: {
|
||||||
) : (
|
) : (
|
||||||
outcomeType === 'BINARY' &&
|
outcomeType === 'BINARY' &&
|
||||||
tradingAllowed(contract) && (
|
tradingAllowed(contract) && (
|
||||||
<BetRow
|
<BetButton
|
||||||
contract={contract as CPMMBinaryContract}
|
contract={contract as CPMMBinaryContract}
|
||||||
className={clsx('mb-2', betRowClassName)}
|
className={clsx('mb-2', betRowClassName)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
|
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
|
||||||
import { Contract } from 'web/lib/firebase/contracts'
|
import { Contract } from 'web/lib/firebase/contracts'
|
||||||
import { Comment } from 'web/lib/firebase/comments'
|
import { ContractComment } from 'common/comment'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
|
|
||||||
const MAX_ACTIVE_CONTRACTS = 75
|
const MAX_ACTIVE_CONTRACTS = 75
|
||||||
|
@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) {
|
||||||
// - Bet on market
|
// - Bet on market
|
||||||
export function findActiveContracts(
|
export function findActiveContracts(
|
||||||
allContracts: Contract[],
|
allContracts: Contract[],
|
||||||
recentComments: Comment[],
|
recentComments: ContractComment[],
|
||||||
recentBets: Bet[],
|
recentBets: Bet[],
|
||||||
seenContracts: { [contractId: string]: number }
|
seenContracts: { [contractId: string]: number }
|
||||||
) {
|
) {
|
||||||
|
@ -73,7 +73,7 @@ export function findActiveContracts(
|
||||||
)
|
)
|
||||||
const contractMostRecentComment = mapValues(
|
const contractMostRecentComment = mapValues(
|
||||||
contractComments,
|
contractComments,
|
||||||
(comments) => maxBy(comments, (c) => c.createdTime) as Comment
|
(comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
|
||||||
)
|
)
|
||||||
|
|
||||||
const prioritizedContracts = sortBy(activeContracts, (c) => {
|
const prioritizedContracts = sortBy(activeContracts, (c) => {
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user'
|
||||||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
import { Comment, GroupComment } from 'common/comment'
|
||||||
|
import { createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||||
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
|
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
|
@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||||
import { usePrivateUser } from 'web/hooks/use-user'
|
import { usePrivateUser } from 'web/hooks/use-user'
|
||||||
|
|
||||||
export function GroupChat(props: {
|
export function GroupChat(props: {
|
||||||
messages: Comment[]
|
messages: GroupComment[]
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
group: Group
|
group: Group
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
|
@ -58,7 +59,7 @@ export function GroupChat(props: {
|
||||||
// array of groups, where each group is an array of messages that are displayed as one
|
// array of groups, where each group is an array of messages that are displayed as one
|
||||||
const groupedMessages = useMemo(() => {
|
const groupedMessages = useMemo(() => {
|
||||||
// Group messages with createdTime within 2 minutes of each other.
|
// Group messages with createdTime within 2 minutes of each other.
|
||||||
const tempGrouped: Comment[][] = []
|
const tempGrouped: GroupComment[][] = []
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (i === 0) tempGrouped.push([message])
|
if (i === 0) tempGrouped.push([message])
|
||||||
|
@ -193,7 +194,7 @@ export function GroupChat(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupChatInBubble(props: {
|
export function GroupChatInBubble(props: {
|
||||||
messages: Comment[]
|
messages: GroupComment[]
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
privateUser: PrivateUser | null | undefined
|
privateUser: PrivateUser | null | undefined
|
||||||
group: Group
|
group: Group
|
||||||
|
@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: {
|
||||||
|
|
||||||
const GroupMessage = memo(function GroupMessage_(props: {
|
const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
comments: Comment[]
|
comments: GroupComment[]
|
||||||
group: Group
|
group: Group
|
||||||
onReplyClick?: (comment: Comment) => void
|
onReplyClick?: (comment: Comment) => void
|
||||||
setRef?: (ref: HTMLDivElement) => void
|
setRef?: (ref: HTMLDivElement) => void
|
||||||
|
|
|
@ -22,20 +22,20 @@ export function LimitBets(props: {
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, className } = props
|
const { contract, bets, className } = props
|
||||||
const sortedBets = sortBy(
|
|
||||||
bets,
|
|
||||||
(bet) => -1 * bet.limitProb,
|
|
||||||
(bet) => -1 * bet.createdTime
|
|
||||||
)
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const yourBets = sortedBets.filter((bet) => bet.userId === user?.id)
|
|
||||||
|
const yourBets = sortBy(
|
||||||
|
bets.filter((bet) => bet.userId === user?.id),
|
||||||
|
(bet) => -1 * bet.limitProb,
|
||||||
|
(bet) => bet.createdTime
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
{yourBets.length === 0 && (
|
{yourBets.length === 0 && (
|
||||||
<OrderBookButton
|
<OrderBookButton
|
||||||
className="self-end"
|
className="self-end"
|
||||||
limitBets={sortedBets}
|
limitBets={bets}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -49,7 +49,7 @@ export function LimitBets(props: {
|
||||||
|
|
||||||
<OrderBookButton
|
<OrderBookButton
|
||||||
className="self-end"
|
className="self-end"
|
||||||
limitBets={sortedBets}
|
limitBets={bets}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -163,8 +163,16 @@ export function OrderBookButton(props: {
|
||||||
const { limitBets, contract, className } = props
|
const { limitBets, contract, className } = props
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const yesBets = limitBets.filter((bet) => bet.outcome === 'YES')
|
const yesBets = sortBy(
|
||||||
const noBets = limitBets.filter((bet) => bet.outcome === 'NO').reverse()
|
limitBets.filter((bet) => bet.outcome === 'YES'),
|
||||||
|
(bet) => -1 * bet.limitProb,
|
||||||
|
(bet) => bet.createdTime
|
||||||
|
)
|
||||||
|
const noBets = sortBy(
|
||||||
|
limitBets.filter((bet) => bet.outcome === 'NO'),
|
||||||
|
(bet) => bet.limitProb,
|
||||||
|
(bet) => bet.createdTime
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
|
||||||
import { NoLabel, YesLabel } from './outcome-label'
|
import { NoLabel, YesLabel } from './outcome-label'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { InfoTooltip } from './info-tooltip'
|
||||||
|
|
||||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="align-center mb-4 text-gray-500">
|
<div className="mb-4 text-gray-500">
|
||||||
Subsidize this market by adding M$ to the liquidity pool.
|
Contribute your M$ to make this market more accurate.{' '}
|
||||||
|
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
|
|
|
@ -33,7 +33,7 @@ function getNavigation() {
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
||||||
|
|
|
@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
|
|
@ -90,13 +90,11 @@ export function FreeResponseOutcomeLabel(props: {
|
||||||
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
||||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||||
return (
|
return (
|
||||||
<Tooltip text={chosen.text}>
|
<AnswerLabel
|
||||||
<AnswerLabel
|
answer={chosen}
|
||||||
answer={chosen}
|
truncate={truncate}
|
||||||
truncate={truncate}
|
className={answerClassName}
|
||||||
className={answerClassName}
|
/>
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,11 +163,13 @@ export function AnswerLabel(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<Tooltip text={truncated === text ? false : text}>
|
||||||
style={{ wordBreak: 'break-word' }}
|
<span
|
||||||
className={clsx('whitespace-pre-line break-words', className)}
|
style={{ wordBreak: 'break-word' }}
|
||||||
>
|
className={clsx('whitespace-pre-line break-words', className)}
|
||||||
{truncated}
|
>
|
||||||
</span>
|
{truncated}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
38
web/components/profile/betting-streak-modal.tsx
Normal file
38
web/components/profile/betting-streak-modal.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import {
|
||||||
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
|
BETTING_STREAK_BONUS_MAX,
|
||||||
|
} from 'common/numeric-constants'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
|
export function BettingStreakModal(props: {
|
||||||
|
isOpen: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { isOpen, setOpen } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>Daily betting streaks</span>
|
||||||
|
<Col className={'gap-2'}>
|
||||||
|
<span className={'text-indigo-700'}>• What are they?</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
|
||||||
|
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
|
||||||
|
. The more days you bet in a row, the more you earn!
|
||||||
|
</span>
|
||||||
|
<span className={'text-indigo-700'}>
|
||||||
|
• Where can I check my streak?
|
||||||
|
</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
You can see your current streak on the top right of your profile
|
||||||
|
page.
|
||||||
|
</span>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,17 +2,21 @@ import React from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { Button } from './button'
|
import { Button, SizeType } from './button'
|
||||||
|
|
||||||
export function SignUpPrompt(props: { label?: string; className?: string }) {
|
export function SignUpPrompt(props: {
|
||||||
const { label, className } = props
|
label?: string
|
||||||
|
className?: string
|
||||||
|
size?: SizeType
|
||||||
|
}) {
|
||||||
|
const { label, className, size = 'lg' } = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
return user === null ? (
|
return user === null ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||||
className={className}
|
className={className}
|
||||||
size="lg"
|
size={size}
|
||||||
color="gradient"
|
color="gradient"
|
||||||
>
|
>
|
||||||
{label ?? 'Sign up to bet!'}
|
{label ?? 'Sign up to bet!'}
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { parseWordsAsTags } from 'common/util/parse'
|
|
||||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
|
||||||
import { Col } from './layout/col'
|
|
||||||
import { Row } from './layout/row'
|
|
||||||
import { TagsList } from './tags-list'
|
|
||||||
import { MAX_TAG_LENGTH } from 'common/contract'
|
|
||||||
|
|
||||||
export function TagsInput(props: { contract: Contract; className?: string }) {
|
|
||||||
const { contract, className } = props
|
|
||||||
const { tags } = contract
|
|
||||||
|
|
||||||
const [tagText, setTagText] = useState('')
|
|
||||||
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
|
|
||||||
const updateTags = async () => {
|
|
||||||
setIsSubmitting(true)
|
|
||||||
await updateContract(contract.id, {
|
|
||||||
tags: newTags,
|
|
||||||
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
|
|
||||||
})
|
|
||||||
setIsSubmitting(false)
|
|
||||||
setTagText('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col className={clsx('gap-4', className)}>
|
|
||||||
<TagsList tags={newTags} noLabel />
|
|
||||||
|
|
||||||
<Row className="items-center gap-4">
|
|
||||||
<input
|
|
||||||
style={{ maxWidth: 150 }}
|
|
||||||
placeholder="Type a tag..."
|
|
||||||
className="input input-sm input-bordered resize-none"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
value={tagText}
|
|
||||||
maxLength={MAX_TAG_LENGTH}
|
|
||||||
onChange={(e) => setTagText(e.target.value || '')}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
updateTags()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-xs btn-outline" onClick={updateTags}>
|
|
||||||
Save tags
|
|
||||||
</button>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,86 +0,0 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { CATEGORIES, category } from '../../common/categories'
|
|
||||||
import { Col } from './layout/col'
|
|
||||||
|
|
||||||
import { Row } from './layout/row'
|
|
||||||
import { SiteLink } from './site-link'
|
|
||||||
|
|
||||||
function Hashtag(props: { tag: string; noLink?: boolean }) {
|
|
||||||
const { tag, noLink } = props
|
|
||||||
const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category]
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<div className={clsx('', !noLink && 'cursor-pointer')}>
|
|
||||||
<span className="text-sm">{category ? '#' + category : tag} </span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (noLink) return body
|
|
||||||
return (
|
|
||||||
<SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center">
|
|
||||||
{body}
|
|
||||||
</SiteLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagsList(props: {
|
|
||||||
tags: string[]
|
|
||||||
className?: string
|
|
||||||
noLink?: boolean
|
|
||||||
noLabel?: boolean
|
|
||||||
label?: string
|
|
||||||
}) {
|
|
||||||
const { tags, className, noLink, noLabel, label } = props
|
|
||||||
return (
|
|
||||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
|
||||||
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Hashtag
|
|
||||||
key={tag}
|
|
||||||
tag={tag.startsWith('#') ? tag : `#${tag}`}
|
|
||||||
noLink={noLink}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FoldTag(props: { fold: { slug: string; name: string } }) {
|
|
||||||
const { fold } = props
|
|
||||||
const { slug, name } = fold
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SiteLink href={`/fold/${slug}`} className="flex items-center">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'rounded-full border-2 bg-white px-4 py-1 shadow-md',
|
|
||||||
'cursor-pointer'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-500">{name}</span>
|
|
||||||
</div>
|
|
||||||
</SiteLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FoldTagList(props: {
|
|
||||||
folds: { slug: string; name: string }[]
|
|
||||||
noLabel?: boolean
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { folds, noLabel, className } = props
|
|
||||||
return (
|
|
||||||
<Col className="gap-2">
|
|
||||||
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
|
|
||||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
|
||||||
{folds.length > 0 && (
|
|
||||||
<>
|
|
||||||
{folds.map((fold) => (
|
|
||||||
<FoldTag key={fold.slug} fold={fold} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contractId =
|
||||||
|
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||||
|
const groupId =
|
||||||
|
comment.commentType === 'group' ? comment.groupId : undefined
|
||||||
await transact({
|
await transact({
|
||||||
amount: change,
|
amount: change,
|
||||||
fromId: user.id,
|
fromId: user.id,
|
||||||
|
@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
toType: 'USER',
|
toType: 'USER',
|
||||||
token: 'M$',
|
token: 'M$',
|
||||||
category: 'TIP',
|
category: 'TIP',
|
||||||
data: {
|
data: { commentId: comment.id, contractId, groupId },
|
||||||
contractId: comment.contractId,
|
|
||||||
commentId: comment.id,
|
|
||||||
groupId: comment.groupId,
|
|
||||||
},
|
|
||||||
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
||||||
})
|
})
|
||||||
|
|
||||||
track('send comment tip', {
|
track('send comment tip', {
|
||||||
contractId: comment.contractId,
|
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
groupId: comment.groupId,
|
contractId,
|
||||||
|
groupId,
|
||||||
amount: change,
|
amount: change,
|
||||||
fromId: user.id,
|
fromId: user.id,
|
||||||
toId: comment.userId,
|
toId: comment.userId,
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
Placement,
|
Placement,
|
||||||
shift,
|
shift,
|
||||||
useFloating,
|
useFloating,
|
||||||
useFocus,
|
|
||||||
useHover,
|
useHover,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
useRole,
|
useRole,
|
||||||
|
@ -48,7 +47,6 @@ export function Tooltip(props: {
|
||||||
|
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||||
useHover(context, { mouseOnly: noTap }),
|
useHover(context, { mouseOnly: noTap }),
|
||||||
useFocus(context),
|
|
||||||
useRole(context, { role: 'tooltip' }),
|
useRole(context, { role: 'tooltip' }),
|
||||||
])
|
])
|
||||||
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
||||||
|
@ -64,7 +62,6 @@ export function Tooltip(props: {
|
||||||
<div
|
<div
|
||||||
className={clsx('inline-block', className)}
|
className={clsx('inline-block', className)}
|
||||||
ref={reference}
|
ref={reference}
|
||||||
tabIndex={noTap ? undefined : 0}
|
|
||||||
{...getReferenceProps()}
|
{...getReferenceProps()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -82,7 +79,7 @@ export function Tooltip(props: {
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
ref={floating}
|
ref={floating}
|
||||||
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
|
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
|
||||||
className="z-10 max-w-xs rounded bg-slate-700 px-2 py-1 text-center text-sm text-white"
|
className="z-10 max-w-xs whitespace-normal rounded bg-slate-700 px-2 py-1 text-center text-sm text-white"
|
||||||
{...getFloatingProps()}
|
{...getFloatingProps()}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -58,8 +59,6 @@ export function UserLink(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
|
||||||
|
|
||||||
export function UserPage(props: { user: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -67,11 +66,29 @@ export function UserPage(props: { user: User }) {
|
||||||
const isCurrentUser = user.id === currentUser?.id
|
const isCurrentUser = user.id === currentUser?.id
|
||||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||||
setShowConfetti(claimedMana)
|
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||||
}, [router])
|
setShowBettingStreakModal(showBettingStreak)
|
||||||
|
setShowConfetti(claimedMana || showBettingStreak)
|
||||||
|
|
||||||
|
const query = { ...router.query }
|
||||||
|
if (query.claimedMana || query.show) {
|
||||||
|
delete query['claimed-mana']
|
||||||
|
delete query['show']
|
||||||
|
router.replace(
|
||||||
|
{
|
||||||
|
pathname: router.pathname,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ shallow: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
const profit = user.profitCached.allTime
|
const profit = user.profitCached.allTime
|
||||||
|
|
||||||
|
@ -85,6 +102,10 @@ export function UserPage(props: { user: User }) {
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||||
)}
|
)}
|
||||||
|
<BettingStreakModal
|
||||||
|
isOpen={showBettingStreakModal}
|
||||||
|
setOpen={setShowBettingStreakModal}
|
||||||
|
/>
|
||||||
{/* Banner image up top, with an circle avatar overlaid */}
|
{/* Banner image up top, with an circle avatar overlaid */}
|
||||||
<div
|
<div
|
||||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||||
|
@ -103,10 +124,10 @@ export function UserPage(props: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top right buttons (e.g. edit, follow) */}
|
{/* Top right buttons (e.g. edit, follow) */}
|
||||||
<div className="absolute right-0 top-0 mt-4 mr-4">
|
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<SiteLink className="btn" href="/profile">
|
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
||||||
<PencilIcon className="h-5 w-5" />{' '}
|
<PencilIcon className="h-5 w-5" />{' '}
|
||||||
<div className="ml-2">Edit</div>
|
<div className="ml-2">Edit</div>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
@ -116,19 +137,34 @@ export function UserPage(props: { user: User }) {
|
||||||
|
|
||||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||||
<Col className="mx-4 -mt-6">
|
<Col className="mx-4 -mt-6">
|
||||||
<span className="text-2xl font-bold">{user.name}</span>
|
<Row className={'justify-between'}>
|
||||||
<span className="text-gray-500">@{user.username}</span>
|
<Col>
|
||||||
<span className="text-gray-500">
|
<span className="text-2xl font-bold">{user.name}</span>
|
||||||
<span
|
<span className="text-gray-500">@{user.username}</span>
|
||||||
className={clsx(
|
</Col>
|
||||||
'text-md',
|
<Col className={'justify-center'}>
|
||||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
<Row className={'gap-3'}>
|
||||||
)}
|
<Col className={'items-center text-gray-500'}>
|
||||||
>
|
<span
|
||||||
{formatMoney(profit)}
|
className={clsx(
|
||||||
</span>{' '}
|
'text-md',
|
||||||
profit
|
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||||
</span>
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(profit)}
|
||||||
|
</span>
|
||||||
|
<span>profit</span>
|
||||||
|
</Col>
|
||||||
|
<Col
|
||||||
|
className={'cursor-pointer items-center text-gray-500'}
|
||||||
|
onClick={() => setShowBettingStreakModal(true)}
|
||||||
|
>
|
||||||
|
<span>🔥{user.currentBettingStreak ?? 0}</span>
|
||||||
|
<span>streak</span>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<>
|
<>
|
||||||
|
@ -138,17 +174,7 @@ export function UserPage(props: { user: User }) {
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
<Row className="flex-wrap items-center gap-2 sm:gap-4">
|
||||||
<Row className="gap-4">
|
|
||||||
<FollowingButton user={user} />
|
|
||||||
<FollowersButton user={user} />
|
|
||||||
{currentUser &&
|
|
||||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
|
||||||
currentUser.username
|
|
||||||
) && <ReferralsButton user={user} />}
|
|
||||||
<GroupsButton user={user} />
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{user.website && (
|
{user.website && (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={
|
href={
|
||||||
|
@ -198,7 +224,7 @@ export function UserPage(props: { user: User }) {
|
||||||
</Row>
|
</Row>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Row>
|
||||||
<Spacer h={5} />
|
<Spacer h={5} />
|
||||||
{currentUser?.id === user.id && (
|
{currentUser?.id === user.id && (
|
||||||
<Row
|
<Row
|
||||||
|
@ -208,7 +234,7 @@ export function UserPage(props: { user: User }) {
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<SiteLink href="/referrals">
|
<SiteLink href="/referrals">
|
||||||
Refer a friend and earn {formatMoney(500)} when they sign up!
|
Earn {formatMoney(500)} when you refer a friend!
|
||||||
</SiteLink>{' '}
|
</SiteLink>{' '}
|
||||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||||
</span>
|
</span>
|
||||||
|
@ -244,6 +270,22 @@ export function UserPage(props: { user: User }) {
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Social',
|
||||||
|
content: (
|
||||||
|
<Row
|
||||||
|
className={'mt-2 flex-wrap items-center justify-center gap-6'}
|
||||||
|
>
|
||||||
|
<FollowingButton user={user} />
|
||||||
|
<FollowersButton user={user} />
|
||||||
|
{currentUser &&
|
||||||
|
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||||
|
currentUser.username
|
||||||
|
) && <ReferralsButton user={user} />}
|
||||||
|
<GroupsButton user={user} />
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -38,7 +38,7 @@ export function YesNoSelector(props: {
|
||||||
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
||||||
selected == 'YES'
|
selected == 'YES'
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-primary bg-transparent',
|
: 'text-primary bg-white',
|
||||||
btnClassName
|
btnClassName
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect('YES')}
|
onClick={() => onSelect('YES')}
|
||||||
|
@ -55,7 +55,7 @@ export function YesNoSelector(props: {
|
||||||
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
|
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
|
||||||
selected == 'NO'
|
selected == 'NO'
|
||||||
? 'bg-red-400 text-white'
|
? 'bg-red-400 text-white'
|
||||||
: 'bg-transparent text-red-400',
|
: 'bg-white text-red-400',
|
||||||
btnClassName
|
btnClassName
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect('NO')}
|
onClick={() => onSelect('NO')}
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import type { feed } from 'common/feed'
|
|
||||||
import { useTimeSinceFirstRender } from './use-time-since-first-render'
|
|
||||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
|
||||||
import { User } from 'common/user'
|
|
||||||
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
|
|
||||||
import {
|
|
||||||
getRecentBetsAndComments,
|
|
||||||
getTopWeeklyContracts,
|
|
||||||
} from 'web/lib/firebase/contracts'
|
|
||||||
|
|
||||||
export const useAlgoFeed = (
|
|
||||||
user: User | null | undefined,
|
|
||||||
category: string
|
|
||||||
) => {
|
|
||||||
const [allFeed, setAllFeed] = useState<feed>()
|
|
||||||
const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>()
|
|
||||||
|
|
||||||
const getTime = useTimeSinceFirstRender()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
getUserFeed(user.id).then((feed) => {
|
|
||||||
if (feed.length === 0) {
|
|
||||||
getDefaultFeed().then((feed) => setAllFeed(feed))
|
|
||||||
} else setAllFeed(feed)
|
|
||||||
|
|
||||||
trackLatency(user.id, 'feed', getTime())
|
|
||||||
console.log('"all" feed load time', getTime())
|
|
||||||
})
|
|
||||||
|
|
||||||
getCategoryFeeds(user.id).then((feeds) => {
|
|
||||||
setCategoryFeeds(feeds)
|
|
||||||
console.log('category feeds load time', getTime())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [user?.id])
|
|
||||||
|
|
||||||
const feed = category === 'all' ? allFeed : categoryFeeds?.[category]
|
|
||||||
|
|
||||||
return feed
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDefaultFeed = async () => {
|
|
||||||
const contracts = await getTopWeeklyContracts()
|
|
||||||
const feed = await Promise.all(
|
|
||||||
contracts.map((c) => getRecentBetsAndComments(c))
|
|
||||||
)
|
|
||||||
return feed
|
|
||||||
}
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||||
import {
|
import {
|
||||||
Comment,
|
|
||||||
listenForCommentsOnContract,
|
listenForCommentsOnContract,
|
||||||
listenForCommentsOnGroup,
|
listenForCommentsOnGroup,
|
||||||
listenForRecentComments,
|
listenForRecentComments,
|
||||||
} from 'web/lib/firebase/comments'
|
} from 'web/lib/firebase/comments'
|
||||||
|
|
||||||
export const useComments = (contractId: string) => {
|
export const useComments = (contractId: string) => {
|
||||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId) return listenForCommentsOnContract(contractId, setComments)
|
if (contractId) return listenForCommentsOnContract(contractId, setComments)
|
||||||
|
@ -16,7 +16,7 @@ export const useComments = (contractId: string) => {
|
||||||
return comments
|
return comments
|
||||||
}
|
}
|
||||||
export const useCommentsOnGroup = (groupId: string | undefined) => {
|
export const useCommentsOnGroup = (groupId: string | undefined) => {
|
||||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
const [comments, setComments] = useState<GroupComment[] | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
|
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
|
||||||
|
|
|
@ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceType === 'bonus' || notification.sourceType === 'tip'
|
notification.sourceType === 'bonus' ||
|
||||||
|
notification.sourceType === 'tip' ||
|
||||||
|
notification.sourceType === 'betting_streak_bonus'
|
||||||
)
|
)
|
||||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
|
notification.sourceType !== 'bonus' &&
|
||||||
|
notification.sourceType !== 'tip' &&
|
||||||
|
notification.sourceType !== 'betting_streak_bonus'
|
||||||
)
|
)
|
||||||
if (incomeNotifications.length > 0) {
|
if (incomeNotifications.length > 0) {
|
||||||
notificationGroups = notificationGroups.concat({
|
notificationGroups = notificationGroups.concat({
|
||||||
|
|
|
@ -29,13 +29,11 @@ export async function createChallenge(data: {
|
||||||
creatorAmount: number
|
creatorAmount: number
|
||||||
acceptorAmount: number
|
acceptorAmount: number
|
||||||
expiresTime: number | null
|
expiresTime: number | null
|
||||||
message: string
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
creator,
|
creator,
|
||||||
creatorAmount,
|
creatorAmount,
|
||||||
expiresTime,
|
expiresTime,
|
||||||
message,
|
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
acceptorAmount,
|
acceptorAmount,
|
||||||
|
@ -73,7 +71,7 @@ export async function createChallenge(data: {
|
||||||
acceptedByUserIds: [],
|
acceptedByUserIds: [],
|
||||||
acceptances: [],
|
acceptances: [],
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
message,
|
message: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(doc(challenges(contract.id), slug), challenge)
|
await setDoc(doc(challenges(contract.id), slug), challenge)
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
import { getValues, listenForValues } from './utils'
|
import { getValues, listenForValues } from './utils'
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { JSONContent } from '@tiptap/react'
|
import { JSONContent } from '@tiptap/react'
|
||||||
|
@ -31,8 +31,10 @@ export async function createCommentOnContract(
|
||||||
const ref = betId
|
const ref = betId
|
||||||
? doc(getCommentsCollection(contractId), betId)
|
? doc(getCommentsCollection(contractId), betId)
|
||||||
: doc(getCommentsCollection(contractId))
|
: doc(getCommentsCollection(contractId))
|
||||||
const comment: Comment = removeUndefinedProps({
|
// contract slug and question are set via trigger
|
||||||
|
const comment = removeUndefinedProps({
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
|
commentType: 'contract',
|
||||||
contractId,
|
contractId,
|
||||||
userId: commenter.id,
|
userId: commenter.id,
|
||||||
content: content,
|
content: content,
|
||||||
|
@ -59,8 +61,9 @@ export async function createCommentOnGroup(
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
) {
|
) {
|
||||||
const ref = doc(getCommentsOnGroupCollection(groupId))
|
const ref = doc(getCommentsOnGroupCollection(groupId))
|
||||||
const comment: Comment = removeUndefinedProps({
|
const comment = removeUndefinedProps({
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
|
commentType: 'group',
|
||||||
groupId,
|
groupId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
content: content,
|
content: content,
|
||||||
|
@ -94,7 +97,7 @@ export async function listAllComments(contractId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllCommentsOnGroup(groupId: string) {
|
export async function listAllCommentsOnGroup(groupId: string) {
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<GroupComment>(
|
||||||
getCommentsOnGroupCollection(groupId)
|
getCommentsOnGroupCollection(groupId)
|
||||||
)
|
)
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
@ -103,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) {
|
||||||
|
|
||||||
export function listenForCommentsOnContract(
|
export function listenForCommentsOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setComments: (comments: Comment[]) => void
|
setComments: (comments: ContractComment[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<Comment>(
|
return listenForValues<ContractComment>(
|
||||||
getCommentsCollection(contractId),
|
getCommentsCollection(contractId),
|
||||||
(comments) => {
|
(comments) => {
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
@ -115,9 +118,9 @@ export function listenForCommentsOnContract(
|
||||||
}
|
}
|
||||||
export function listenForCommentsOnGroup(
|
export function listenForCommentsOnGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
setComments: (comments: Comment[]) => void
|
setComments: (comments: GroupComment[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<Comment>(
|
return listenForValues<GroupComment>(
|
||||||
getCommentsOnGroupCollection(groupId),
|
getCommentsOnGroupCollection(groupId),
|
||||||
(comments) => {
|
(comments) => {
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import {
|
import {
|
||||||
collection,
|
collection,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
|
@ -17,15 +16,13 @@ import { sortBy, sum } from 'lodash'
|
||||||
|
|
||||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { BinaryContract, Contract } from 'common/contract'
|
import { BinaryContract, Contract } from 'common/contract'
|
||||||
import { getDpmProbability } from 'common/calculate-dpm'
|
|
||||||
import { createRNG, shuffle } from 'common/util/random'
|
import { createRNG, shuffle } from 'common/util/random'
|
||||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
|
||||||
import { formatMoney, formatPercent } from 'common/util/format'
|
import { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { getBinaryProb } from 'common/contract-details'
|
||||||
|
|
||||||
export const contracts = coll<Contract>('contracts')
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
|
@ -50,20 +47,6 @@ export function contractUrl(contract: Contract) {
|
||||||
return `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
return `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contractMetrics(contract: Contract) {
|
|
||||||
const { createdTime, resolutionTime, isResolved } = contract
|
|
||||||
|
|
||||||
const createdDate = dayjs(createdTime).format('MMM D')
|
|
||||||
|
|
||||||
const resolvedDate = isResolved
|
|
||||||
? dayjs(resolutionTime).format('MMM D')
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const volumeLabel = `${formatMoney(contract.volume)} bet`
|
|
||||||
|
|
||||||
return { volumeLabel, createdDate, resolvedDate }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contractPool(contract: Contract) {
|
export function contractPool(contract: Contract) {
|
||||||
return contract.mechanism === 'cpmm-1'
|
return contract.mechanism === 'cpmm-1'
|
||||||
? formatMoney(contract.totalLiquidity)
|
? formatMoney(contract.totalLiquidity)
|
||||||
|
@ -72,17 +55,6 @@ export function contractPool(contract: Contract) {
|
||||||
: 'Empty pool'
|
: 'Empty pool'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBinaryProb(contract: BinaryContract) {
|
|
||||||
const { pool, resolutionProbability, mechanism } = contract
|
|
||||||
|
|
||||||
return (
|
|
||||||
resolutionProbability ??
|
|
||||||
(mechanism === 'cpmm-1'
|
|
||||||
? getCpmmProbability(pool, contract.p)
|
|
||||||
: getDpmProbability(contract.totalShares))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBinaryProbPercent(contract: BinaryContract) {
|
export function getBinaryProbPercent(contract: BinaryContract) {
|
||||||
return formatPercent(getBinaryProb(contract))
|
return formatPercent(getBinaryProb(contract))
|
||||||
}
|
}
|
||||||
|
@ -285,16 +257,6 @@ export async function getContractsBySlugs(slugs: string[]) {
|
||||||
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topWeeklyQuery = query(
|
|
||||||
contracts,
|
|
||||||
where('isResolved', '==', false),
|
|
||||||
orderBy('volume7Days', 'desc'),
|
|
||||||
limit(MAX_FEED_CONTRACTS)
|
|
||||||
)
|
|
||||||
export async function getTopWeeklyContracts() {
|
|
||||||
return await getValues<Contract>(topWeeklyQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closingSoonQuery = query(
|
const closingSoonQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
|
|
@ -14,20 +14,10 @@ import {
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { getAuth } from 'firebase/auth'
|
import { getAuth } from 'firebase/auth'
|
||||||
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
|
||||||
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
||||||
import { zip } from 'lodash'
|
|
||||||
import { app, db } from './init'
|
import { app, db } from './init'
|
||||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||||
import {
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
coll,
|
|
||||||
getValue,
|
|
||||||
getValues,
|
|
||||||
listenForValue,
|
|
||||||
listenForValues,
|
|
||||||
} from './utils'
|
|
||||||
import { feed } from 'common/feed'
|
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
|
||||||
import { safeLocalStorage } from '../util/local'
|
import { safeLocalStorage } from '../util/local'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
|
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
|
||||||
|
@ -202,20 +192,6 @@ export async function firebaseLogout() {
|
||||||
await auth.signOut()
|
await auth.signOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = getStorage(app)
|
|
||||||
// Example: uploadData('avatars/ajfi8iejsf.png', data)
|
|
||||||
export async function uploadData(
|
|
||||||
path: string,
|
|
||||||
data: ArrayBuffer | Blob | Uint8Array
|
|
||||||
) {
|
|
||||||
const uploadRef = ref(storage, path)
|
|
||||||
// Uploaded files should be cached for 1 day, then revalidated
|
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
|
||||||
const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' }
|
|
||||||
await uploadBytes(uploadRef, data, metadata)
|
|
||||||
return await getDownloadURL(uploadRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listUsers(userIds: string[]) {
|
export async function listUsers(userIds: string[]) {
|
||||||
if (userIds.length > 10) {
|
if (userIds.length > 10) {
|
||||||
throw new Error('Too many users requested at once; Firestore limits to 10')
|
throw new Error('Too many users requested at once; Firestore limits to 10')
|
||||||
|
@ -263,25 +239,6 @@ export function getUsers() {
|
||||||
return getValues<User>(users)
|
return getValues<User>(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserFeed(userId: string) {
|
|
||||||
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
|
|
||||||
const userFeed = await getValue<{
|
|
||||||
feed: feed
|
|
||||||
}>(feedDoc)
|
|
||||||
return userFeed?.feed ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCategoryFeeds(userId: string) {
|
|
||||||
const cacheCollection = collection(privateUsers, userId, 'cache')
|
|
||||||
const feedData = await Promise.all(
|
|
||||||
CATEGORY_LIST.map((category) =>
|
|
||||||
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const feeds = feedData.map((data) => data?.feed ?? [])
|
|
||||||
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][])
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function follow(userId: string, followedUserId: string) {
|
export async function follow(userId: string, followedUserId: string) {
|
||||||
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
||||||
await setDoc(followDoc, {
|
await setDoc(followDoc, {
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
||||||
import { Comment, listAllComments } from 'web/lib/firebase/comments'
|
import { listAllComments } from 'web/lib/firebase/comments'
|
||||||
import Custom404 from '../404'
|
import Custom404 from '../404'
|
||||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||||
|
@ -36,12 +36,13 @@ import { AlertBox } from 'web/components/alert-box'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { ContractComment } from 'common/comment'
|
||||||
import { listUsers } from 'web/lib/firebase/users'
|
import { listUsers } from 'web/lib/firebase/users'
|
||||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||||
|
import { getOpenGraphProps } from 'common/contract-details'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -78,7 +79,7 @@ export default function ContractPage(props: {
|
||||||
contract: Contract | null
|
contract: Contract | null
|
||||||
username: string
|
username: string
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
slug: string
|
slug: string
|
||||||
backToHome?: () => void
|
backToHome?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
||||||
function ContractTopTrades(props: {
|
function ContractTopTrades(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, comments, tips } = props
|
const { contract, bets, comments, tips } = props
|
||||||
|
|
|
@ -28,11 +28,11 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
|
||||||
import Custom404 from 'web/pages/404'
|
import Custom404 from 'web/pages/404'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { BinaryContract } from 'common/contract'
|
import { BinaryContract } from 'common/contract'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
|
import { getOpenGraphProps } from 'common/contract-details'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
useAcceptedChallenges,
|
useAcceptedChallenges,
|
||||||
useUserChallenges,
|
useUserChallenges,
|
||||||
} from 'web/lib/firebase/challenges'
|
} from 'web/lib/firebase/challenges'
|
||||||
import { Challenge } from 'common/challenge'
|
import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
|
@ -29,6 +29,7 @@ import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { QRCode } from 'web/components/qr-code'
|
import { QRCode } from 'web/components/qr-code'
|
||||||
|
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||||
|
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
|
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
|
||||||
|
@ -37,6 +38,7 @@ const amountClass = columnClass + ' max-w-[75px] font-bold'
|
||||||
export default function ChallengesListPage() {
|
export default function ChallengesListPage() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const challenges = useAcceptedChallenges()
|
const challenges = useAcceptedChallenges()
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
const userChallenges = useUserChallenges(user?.id)
|
const userChallenges = useUserChallenges(user?.id)
|
||||||
.concat(
|
.concat(
|
||||||
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
|
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
|
||||||
|
@ -70,8 +72,25 @@ export default function ChallengesListPage() {
|
||||||
<Col className="w-full px-8">
|
<Col className="w-full px-8">
|
||||||
<Row className="items-center justify-between">
|
<Row className="items-center justify-between">
|
||||||
<Title text="Challenges" />
|
<Title text="Challenges" />
|
||||||
|
{CHALLENGES_ENABLED && (
|
||||||
|
<Button size="lg" color="gradient" onClick={() => setOpen(true)}>
|
||||||
|
Create Challenge
|
||||||
|
<CreateChallengeModal
|
||||||
|
isOpen={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<p>Find or create a question to challenge someone to a bet.</p>
|
<p>
|
||||||
|
Want to create your own challenge?
|
||||||
|
<SiteLink className={'mx-1 font-bold'} href={'/home'}>
|
||||||
|
Find
|
||||||
|
</SiteLink>
|
||||||
|
a market you and a friend disagree on and hit the challenge button, or
|
||||||
|
tap the button above to create a new market & challenge in one.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Tabs tabs={[...userTab, ...publicTab]} />
|
<Tabs tabs={[...userTab, ...publicTab]} />
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -26,7 +26,9 @@ import { User } from 'common/user'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const txns = await getAllCharityTxns()
|
let txns = await getAllCharityTxns()
|
||||||
|
// Sort by newest txns first
|
||||||
|
txns = sortBy(txns, 'createdTime').reverse()
|
||||||
const totals = mapValues(groupBy(txns, 'toId'), (txns) =>
|
const totals = mapValues(groupBy(txns, 'toId'), (txns) =>
|
||||||
sumBy(txns, (txn) => txn.amount)
|
sumBy(txns, (txn) => txn.amount)
|
||||||
)
|
)
|
||||||
|
@ -37,7 +39,8 @@ export async function getStaticProps() {
|
||||||
])
|
])
|
||||||
const matches = quadraticMatches(txns, totalRaised)
|
const matches = quadraticMatches(txns, totalRaised)
|
||||||
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
||||||
const mostRecentDonor = await getUser(txns[txns.length - 1].fromId)
|
const mostRecentDonor = await getUser(txns[0].fromId)
|
||||||
|
const mostRecentCharity = txns[0].toId
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
@ -47,6 +50,7 @@ export async function getStaticProps() {
|
||||||
txns,
|
txns,
|
||||||
numDonors,
|
numDonors,
|
||||||
mostRecentDonor,
|
mostRecentDonor,
|
||||||
|
mostRecentCharity,
|
||||||
},
|
},
|
||||||
revalidate: 60,
|
revalidate: 60,
|
||||||
}
|
}
|
||||||
|
@ -71,7 +75,7 @@ function DonatedStats(props: { stats: Stat[] }) {
|
||||||
{stat.name}
|
{stat.name}
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">
|
<dd className="mt-1 text-2xl font-semibold text-gray-900">
|
||||||
{stat.url ? (
|
{stat.url ? (
|
||||||
<SiteLink href={stat.url}>{stat.stat}</SiteLink>
|
<SiteLink href={stat.url}>{stat.stat}</SiteLink>
|
||||||
) : (
|
) : (
|
||||||
|
@ -91,11 +95,21 @@ export default function Charity(props: {
|
||||||
txns: Txn[]
|
txns: Txn[]
|
||||||
numDonors: number
|
numDonors: number
|
||||||
mostRecentDonor: User
|
mostRecentDonor: User
|
||||||
|
mostRecentCharity: string
|
||||||
}) {
|
}) {
|
||||||
const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props
|
const {
|
||||||
|
totalRaised,
|
||||||
|
charities,
|
||||||
|
matches,
|
||||||
|
mostRecentCharity,
|
||||||
|
mostRecentDonor,
|
||||||
|
} = props
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const debouncedQuery = debounce(setQuery, 50)
|
const debouncedQuery = debounce(setQuery, 50)
|
||||||
|
const recentCharityName =
|
||||||
|
charities.find((charity) => charity.id === mostRecentCharity)?.name ??
|
||||||
|
'Nobody'
|
||||||
|
|
||||||
const filterCharities = useMemo(
|
const filterCharities = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -143,15 +157,16 @@ export default function Charity(props: {
|
||||||
name: 'Raised by Manifold users',
|
name: 'Raised by Manifold users',
|
||||||
stat: manaToUSD(totalRaised),
|
stat: manaToUSD(totalRaised),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Number of donors',
|
|
||||||
stat: `${numDonors}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Most recent donor',
|
name: 'Most recent donor',
|
||||||
stat: mostRecentDonor.name ?? 'Nobody',
|
stat: mostRecentDonor.name ?? 'Nobody',
|
||||||
url: `/${mostRecentDonor.username}`,
|
url: `/${mostRecentDonor.username}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Most recent donation',
|
||||||
|
stat: recentCharityName,
|
||||||
|
url: `/charity/${mostRecentCharity}`,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Spacer h={10} />
|
<Spacer h={10} />
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
MAX_DESCRIPTION_LENGTH,
|
MAX_DESCRIPTION_LENGTH,
|
||||||
MAX_QUESTION_LENGTH,
|
MAX_QUESTION_LENGTH,
|
||||||
outcomeType,
|
outcomeType,
|
||||||
|
visibility,
|
||||||
} from 'common/contract'
|
} from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
@ -150,6 +151,7 @@ export function NewContract(props: {
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
const [showGroupSelector, setShowGroupSelector] = useState(true)
|
const [showGroupSelector, setShowGroupSelector] = useState(true)
|
||||||
|
const [visibility, setVisibility] = useState<visibility>('public')
|
||||||
|
|
||||||
const closeTime = closeDate
|
const closeTime = closeDate
|
||||||
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
||||||
|
@ -234,6 +236,7 @@ export function NewContract(props: {
|
||||||
isLogScale,
|
isLogScale,
|
||||||
answers,
|
answers,
|
||||||
groupId: selectedGroup?.id,
|
groupId: selectedGroup?.id,
|
||||||
|
visibility,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
track('create market', {
|
track('create market', {
|
||||||
|
@ -367,17 +370,33 @@ export function NewContract(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={'mt-2'}>
|
<div className="form-control mb-1 items-start gap-1">
|
||||||
<GroupSelector
|
<label className="label gap-2">
|
||||||
selectedGroup={selectedGroup}
|
<span className="mb-1">Visibility</span>
|
||||||
setSelectedGroup={setSelectedGroup}
|
<InfoTooltip text="Whether the market will be listed on the home page." />
|
||||||
creator={creator}
|
</label>
|
||||||
options={{ showSelector: showGroupSelector, showLabel: true }}
|
<ChoicesToggleGroup
|
||||||
|
currentChoice={visibility}
|
||||||
|
setChoice={(choice) => setVisibility(choice as visibility)}
|
||||||
|
choicesMap={{
|
||||||
|
Public: 'public',
|
||||||
|
Unlisted: 'unlisted',
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
|
|
||||||
|
<GroupSelector
|
||||||
|
selectedGroup={selectedGroup}
|
||||||
|
setSelectedGroup={setSelectedGroup}
|
||||||
|
creator={creator}
|
||||||
|
options={{ showSelector: showGroupSelector, showLabel: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Spacer h={6} />
|
||||||
|
|
||||||
<div className="form-control mb-1 items-start">
|
<div className="form-control mb-1 items-start">
|
||||||
<label className="label mb-1 gap-2">
|
<label className="label mb-1 gap-2">
|
||||||
<span>Question closes in</span>
|
<span>Question closes in</span>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { DOMAIN } from 'common/envs/constants'
|
import { DOMAIN } from 'common/envs/constants'
|
||||||
|
import { useState } from 'react'
|
||||||
import { AnswersGraph } from 'web/components/answers/answers-graph'
|
import { AnswersGraph } from 'web/components/answers/answers-graph'
|
||||||
import BetRow from 'web/components/bet-row'
|
import { BetInline } from 'web/components/bet-inline'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
import {
|
import {
|
||||||
BinaryResolutionOrChance,
|
BinaryResolutionOrChance,
|
||||||
FreeResponseResolutionOrChance,
|
FreeResponseResolutionOrChance,
|
||||||
|
@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
||||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { listAllBets } from 'web/lib/firebase/bets'
|
import { listAllBets } from 'web/lib/firebase/bets'
|
||||||
import {
|
import {
|
||||||
contractPath,
|
contractPath,
|
||||||
|
@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
|
|
||||||
const href = `https://${DOMAIN}${contractPath(contract)}`
|
const href = `https://${DOMAIN}${contractPath(contract)}`
|
||||||
|
|
||||||
const { height: windowHeight } = useWindowSize()
|
const { setElem, height: graphHeight } = useMeasureSize()
|
||||||
const { setElem, height: topSectionHeight } = useMeasureSize()
|
|
||||||
const paddingBottom = 8
|
|
||||||
|
|
||||||
const graphHeight =
|
const [betPanelOpen, setBetPanelOpen] = useState(false)
|
||||||
windowHeight && topSectionHeight
|
|
||||||
? windowHeight - topSectionHeight - paddingBottom
|
const [probAfter, setProbAfter] = useState<number>()
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="w-full flex-1 bg-white">
|
<Col className="h-[100vh] w-full bg-white">
|
||||||
<div className="relative flex flex-col pt-2" ref={setElem}>
|
<div className="relative flex flex-col pt-2">
|
||||||
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
||||||
<SiteLink href={href}>{question}</SiteLink>
|
<SiteLink href={href}>{question}</SiteLink>
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(isBinary || isPseudoNumeric) &&
|
||||||
|
tradingAllowed(contract) &&
|
||||||
|
!betPanelOpen && (
|
||||||
|
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
||||||
|
Bet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isBinary && (
|
{isBinary && (
|
||||||
<Row className="items-center gap-4">
|
<BinaryResolutionOrChance
|
||||||
{tradingAllowed(contract) && (
|
contract={contract}
|
||||||
<BetRow
|
probAfter={probAfter}
|
||||||
contract={contract as CPMMBinaryContract}
|
className="items-center"
|
||||||
betPanelClassName="scale-75"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
|
||||||
</Row>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPseudoNumeric && (
|
{isPseudoNumeric && (
|
||||||
<Row className="items-center gap-4">
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && (
|
|
||||||
<BetRow contract={contract} betPanelClassName="scale-75" />
|
|
||||||
)}
|
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
|
||||||
</Row>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{outcomeType === 'FREE_RESPONSE' && (
|
||||||
|
@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<Spacer h={2} />
|
<Spacer h={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-1" style={{ paddingBottom }}>
|
{(isBinary || isPseudoNumeric) && betPanelOpen && (
|
||||||
|
<BetInline
|
||||||
|
contract={contract as any}
|
||||||
|
setProbAfter={setProbAfter}
|
||||||
|
onClose={() => setBetPanelOpen(false)}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
|
||||||
{(isBinary || isPseudoNumeric) && (
|
{(isBinary || isPseudoNumeric) && (
|
||||||
<ContractProbGraph
|
<ContractProbGraph
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
|
|
@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||||
import { Comment } from 'common/comment'
|
import { GroupComment } from 'common/comment'
|
||||||
import { GroupChat } from 'web/components/groups/group-chat'
|
import { GroupChat } from 'web/components/groups/group-chat'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
|
@ -123,7 +123,7 @@ export default function GroupPage(props: {
|
||||||
topTraders: User[]
|
topTraders: User[]
|
||||||
creatorScores: { [userId: string]: number }
|
creatorScores: { [userId: string]: number }
|
||||||
topCreators: User[]
|
topCreators: User[]
|
||||||
messages: Comment[]
|
messages: GroupComment[]
|
||||||
}) {
|
}) {
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
group: null,
|
group: null,
|
||||||
|
@ -551,7 +551,12 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
<Button size="sm" color="gradient" onClick={() => setOpen(true)}>
|
<Button
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
size="sm"
|
||||||
|
color="gradient"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
Add market
|
Add market
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -607,9 +612,6 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
user={user}
|
user={user}
|
||||||
hideOrderSelector={true}
|
hideOrderSelector={true}
|
||||||
onContractClick={addContractToCurrentGroup}
|
onContractClick={addContractToCurrentGroup}
|
||||||
overrideGridClassName={
|
|
||||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
|
||||||
}
|
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||||
additionalFilter={{ excludeContractIds: group.contractIds }}
|
additionalFilter={{ excludeContractIds: group.contractIds }}
|
||||||
highlightOptions={{
|
highlightOptions={{
|
||||||
|
|
|
@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
||||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
|
import { GetServerSideProps } from 'next'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
|
const creds = await authenticateOnServer(ctx)
|
||||||
})
|
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
|
||||||
|
return { props: { auth } }
|
||||||
|
}
|
||||||
|
|
||||||
const Home = (props: { auth: { user: User } }) => {
|
const Home = (props: { auth: { user: User } | null }) => {
|
||||||
const { user } = props.auth
|
const user = props.auth ? props.auth.user : null
|
||||||
const [contract, setContract] = useContractPage()
|
const [contract, setContract] = useContractPage()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -42,6 +45,7 @@ const Home = (props: { auth: { user: User } }) => {
|
||||||
// Update the url without switching pages in Nextjs.
|
// Update the url without switching pages in Nextjs.
|
||||||
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
|
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
|
||||||
}}
|
}}
|
||||||
|
isWholePage
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) {
|
||||||
<Col className="items-center">
|
<Col className="items-center">
|
||||||
<Col className="max-w-3xl">
|
<Col className="max-w-3xl">
|
||||||
<LandingPagePanel hotContracts={hotContracts ?? []} />
|
<LandingPagePanel hotContracts={hotContracts ?? []} />
|
||||||
{/* <p className="mt-6 text-gray-500">
|
|
||||||
View{' '}
|
|
||||||
<SiteLink href="/markets" className="font-bold text-gray-700">
|
|
||||||
all markets
|
|
||||||
</SiteLink>
|
|
||||||
</p> */}
|
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import { ContractSearch } from '../components/contract-search'
|
|
||||||
import { Page } from '../components/page'
|
|
||||||
import { SEO } from '../components/SEO'
|
|
||||||
|
|
||||||
// TODO: Rename endpoint to "Explore"
|
|
||||||
export default function Markets() {
|
|
||||||
const user = useUser()
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<SEO
|
|
||||||
title="Explore"
|
|
||||||
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
|
|
||||||
url="/markets"
|
|
||||||
/>
|
|
||||||
<ContractSearch user={user} />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -31,7 +31,10 @@ import {
|
||||||
import { TrendingUpIcon } from '@heroicons/react/outline'
|
import { TrendingUpIcon } from '@heroicons/react/outline'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
|
import {
|
||||||
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
|
} from 'common/numeric-constants'
|
||||||
import { groupBy, sum, uniq } from 'lodash'
|
import { groupBy, sum, uniq } from 'lodash'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { Pagination } from 'web/components/pagination'
|
import { Pagination } from 'web/components/pagination'
|
||||||
|
@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: {
|
||||||
(n) => n.sourceType
|
(n) => n.sourceType
|
||||||
)
|
)
|
||||||
for (const sourceType in groupedNotificationsBySourceType) {
|
for (const sourceType in groupedNotificationsBySourceType) {
|
||||||
// Source title splits by contracts and groups
|
// Source title splits by contracts, groups, betting streak bonus
|
||||||
const groupedNotificationsBySourceTitle = groupBy(
|
const groupedNotificationsBySourceTitle = groupBy(
|
||||||
groupedNotificationsBySourceType[sourceType],
|
groupedNotificationsBySourceType[sourceType],
|
||||||
(notification) => {
|
(notification) => {
|
||||||
return notification.sourceTitle ?? notification.sourceContractTitle
|
return notification.sourceTitle ?? notification.sourceContractTitle
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for (const contractId in groupedNotificationsBySourceTitle) {
|
for (const sourceTitle in groupedNotificationsBySourceTitle) {
|
||||||
const notificationsForContractId =
|
const notificationsForSourceTitle =
|
||||||
groupedNotificationsBySourceTitle[contractId]
|
groupedNotificationsBySourceTitle[sourceTitle]
|
||||||
if (notificationsForContractId.length === 1) {
|
if (notificationsForSourceTitle.length === 1) {
|
||||||
newNotifications.push(notificationsForContractId[0])
|
newNotifications.push(notificationsForSourceTitle[0])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let sum = 0
|
let sum = 0
|
||||||
notificationsForContractId.forEach(
|
notificationsForSourceTitle.forEach(
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceText &&
|
notification.sourceText &&
|
||||||
(sum = parseInt(notification.sourceText) + sum)
|
(sum = parseInt(notification.sourceText) + sum)
|
||||||
)
|
)
|
||||||
const uniqueUsers = uniq(
|
const uniqueUsers = uniq(
|
||||||
notificationsForContractId.map((notification) => {
|
notificationsForSourceTitle.map((notification) => {
|
||||||
return notification.sourceUserUsername
|
return notification.sourceUserUsername
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const newNotification = {
|
const newNotification = {
|
||||||
...notificationsForContractId[0],
|
...notificationsForSourceTitle[0],
|
||||||
sourceText: sum.toString(),
|
sourceText: sum.toString(),
|
||||||
sourceUserUsername:
|
sourceUserUsername:
|
||||||
uniqueUsers.length > 1
|
uniqueUsers.length > 1
|
||||||
? MULTIPLE_USERS_KEY
|
? MULTIPLE_USERS_KEY
|
||||||
: notificationsForContractId[0].sourceType,
|
: notificationsForSourceTitle[0].sourceType,
|
||||||
}
|
}
|
||||||
newNotifications.push(newNotification)
|
newNotifications.push(newNotification)
|
||||||
}
|
}
|
||||||
|
@ -362,7 +365,8 @@ function IncomeNotificationItem(props: {
|
||||||
justSummary?: boolean
|
justSummary?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { notification, justSummary } = props
|
const { notification, justSummary } = props
|
||||||
const { sourceType, sourceUserName, sourceUserUsername } = notification
|
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
|
||||||
|
notification
|
||||||
const [highlighted] = useState(!notification.isSeen)
|
const [highlighted] = useState(!notification.isSeen)
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const isMobile = (width && width < 768) || false
|
const isMobile = (width && width < 768) || false
|
||||||
|
@ -370,19 +374,82 @@ function IncomeNotificationItem(props: {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
|
||||||
function getReasonForShowingIncomeNotification(simple: boolean) {
|
function reasonAndLink(simple: boolean) {
|
||||||
const { sourceText } = notification
|
const { sourceText } = notification
|
||||||
let reasonText = ''
|
let reasonText = ''
|
||||||
|
|
||||||
if (sourceType === 'bonus' && sourceText) {
|
if (sourceType === 'bonus' && sourceText) {
|
||||||
reasonText = !simple
|
reasonText = !simple
|
||||||
? `Bonus for ${
|
? `Bonus for ${
|
||||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||||
} unique traders`
|
} unique traders on`
|
||||||
: 'bonus on'
|
: 'bonus on'
|
||||||
} else if (sourceType === 'tip') {
|
} else if (sourceType === 'tip') {
|
||||||
reasonText = !simple ? `tipped you` : `in tips on`
|
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||||
|
} else if (sourceType === 'betting_streak_bonus') {
|
||||||
|
reasonText = 'for your'
|
||||||
}
|
}
|
||||||
return reasonText
|
|
||||||
|
const bettingStreakText =
|
||||||
|
sourceType === 'betting_streak_bonus' &&
|
||||||
|
(sourceText
|
||||||
|
? `🔥 ${
|
||||||
|
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
|
||||||
|
} day Betting Streak`
|
||||||
|
: 'Betting Streak')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{reasonText}
|
||||||
|
{sourceType === 'betting_streak_bonus' ? (
|
||||||
|
simple ? (
|
||||||
|
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||||
|
) : (
|
||||||
|
<SiteLink
|
||||||
|
className={'ml-1 font-bold'}
|
||||||
|
href={'/betting-streak-bonus'}
|
||||||
|
>
|
||||||
|
{bettingStreakText}
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<QuestionOrGroupLink
|
||||||
|
notification={notification}
|
||||||
|
ignoreClick={isMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomeNotificationLabel = () => {
|
||||||
|
return sourceText ? (
|
||||||
|
<span className="text-primary">
|
||||||
|
{'+' + formatMoney(parseInt(sourceText))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIncomeSourceUrl = () => {
|
||||||
|
const {
|
||||||
|
sourceId,
|
||||||
|
sourceContractCreatorUsername,
|
||||||
|
sourceContractSlug,
|
||||||
|
sourceSlug,
|
||||||
|
} = notification
|
||||||
|
if (sourceType === 'tip' && sourceContractSlug)
|
||||||
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||||
|
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||||
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
|
if (sourceType === 'betting_streak_bonus')
|
||||||
|
return `/${sourceUserUsername}/?show=betting-streak`
|
||||||
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
|
sourceId ?? '',
|
||||||
|
sourceType
|
||||||
|
)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (justSummary) {
|
if (justSummary) {
|
||||||
|
@ -392,19 +459,9 @@ function IncomeNotificationItem(props: {
|
||||||
<div className={'flex pl-1 sm:pl-0'}>
|
<div className={'flex pl-1 sm:pl-0'}>
|
||||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||||
<div className={'mr-1 text-black'}>
|
<div className={'mr-1 text-black'}>
|
||||||
<NotificationTextLabel
|
{incomeNotificationLabel()}
|
||||||
className={'line-clamp-1'}
|
|
||||||
notification={notification}
|
|
||||||
justSummary={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={'flex truncate'}>
|
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
|
||||||
{getReasonForShowingIncomeNotification(true)}
|
|
||||||
<QuestionOrGroupLink
|
|
||||||
notification={notification}
|
|
||||||
ignoreClick={isMobile}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -421,18 +478,16 @@ function IncomeNotificationItem(props: {
|
||||||
>
|
>
|
||||||
<div className={'relative'}>
|
<div className={'relative'}>
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={getSourceUrl(notification) ?? ''}
|
href={getIncomeSourceUrl() ?? ''}
|
||||||
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
|
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
|
||||||
/>
|
/>
|
||||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||||
<div className={'line-clamp-2 flex max-w-xl shrink '}>
|
<div className={'line-clamp-2 flex max-w-xl shrink '}>
|
||||||
<div className={'inline'}>
|
<div className={'inline'}>
|
||||||
<span className={'mr-1'}>
|
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
|
||||||
<NotificationTextLabel notification={notification} />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
{sourceType != 'bonus' &&
|
{sourceType === 'tip' &&
|
||||||
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
||||||
<span className={'mr-1 truncate'}>Multiple users</span>
|
<span className={'mr-1 truncate'}>Multiple users</span>
|
||||||
) : (
|
) : (
|
||||||
|
@ -443,8 +498,7 @@ function IncomeNotificationItem(props: {
|
||||||
short={true}
|
short={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{getReasonForShowingIncomeNotification(false)} {' on'}
|
{reasonAndLink(false)}
|
||||||
<QuestionOrGroupLink notification={notification} />
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -794,9 +848,6 @@ function getSourceUrl(notification: Notification) {
|
||||||
// User referral:
|
// User referral:
|
||||||
if (sourceType === 'user' && !sourceContractSlug)
|
if (sourceType === 'user' && !sourceContractSlug)
|
||||||
return `/${sourceUserUsername}`
|
return `/${sourceUserUsername}`
|
||||||
if (sourceType === 'tip' && sourceContractSlug)
|
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
|
||||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
|
||||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
|
@ -885,12 +936,6 @@ function NotificationTextLabel(props: {
|
||||||
return (
|
return (
|
||||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||||
)
|
)
|
||||||
} else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
|
|
||||||
return (
|
|
||||||
<span className="text-primary">
|
|
||||||
{'+' + formatMoney(parseInt(sourceText))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (sourceType === 'bet' && sourceText) {
|
} else if (sourceType === 'bet' && sourceText) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import { ContractSearch } from '../../components/contract-search'
|
|
||||||
import { Page } from '../../components/page'
|
|
||||||
import { Title } from '../../components/title'
|
|
||||||
|
|
||||||
export default function TagPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const user = useUser()
|
|
||||||
const { tag } = router.query as { tag: string }
|
|
||||||
if (!router.isReady) return <div />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<Title text={`#${tag}`} />
|
|
||||||
<ContractSearch
|
|
||||||
user={user}
|
|
||||||
defaultSort="newest"
|
|
||||||
defaultFilter="all"
|
|
||||||
additionalFilter={{ tag }}
|
|
||||||
/>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||||
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
||||||
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
|
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
|
||||||
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|
|
@ -9947,6 +9947,11 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.10.3"
|
"@babel/runtime" "^7.10.3"
|
||||||
|
|
||||||
|
react-masonry-css@1.0.16:
|
||||||
|
version "1.0.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c"
|
||||||
|
integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==
|
||||||
|
|
||||||
react-motion@^0.5.2:
|
react-motion@^0.5.2:
|
||||||
version "0.5.2"
|
version "0.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"
|
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user