Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-08-21 12:32:48 -07:00 committed by GitHub
commit 9b6fda0c31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 2230 additions and 1503 deletions

View File

@ -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 Funds 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 Funds 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 states 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 {

View File

@ -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
View 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
)
}

View File

@ -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

View File

@ -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,

View File

@ -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'

View File

@ -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

View File

@ -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]])
)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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} {

View File

@ -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"
}, },

View File

@ -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 }
}) })

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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))
}

View File

@ -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 }

View File

@ -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"
&nbsp; 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">
&nbsp;
</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"> &nbsp;
&nbsp; </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!&nbsp;</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&apos;s weekly active
users.</span>
</li>
</ul>
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
&nbsp;
</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"> &nbsp;
<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>

View 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>

View File

@ -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;">&nbsp;</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;">&nbsp;</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;"

View File

@ -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))
}

View File

@ -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'

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View 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
)
})

View 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,
})
}

View 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.`)
})
}

View 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))
}
}

View 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())

View File

@ -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'

View File

@ -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)

View File

@ -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

View 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)
}

View File

@ -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

View File

@ -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)
} }
} }

View File

@ -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 =

View File

@ -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

View 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>
)
}

View File

@ -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)}?

View File

@ -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
}) { }) {

View File

@ -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!" />

View File

@ -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])

View File

@ -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}`
: '', : '',

View File

@ -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,
}
}

View File

@ -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
} }

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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' ||

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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),

View File

@ -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}

View File

@ -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,

View File

@ -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>
)
}

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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)}
/> />

View File

@ -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) => {

View File

@ -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

View File

@ -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 (
<> <>

View File

@ -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>

View File

@ -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

View File

@ -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',

View File

@ -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>
) )
} }

View 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>
)
}

View File

@ -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!'}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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}

View File

@ -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>

View File

@ -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')}

View File

@ -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
}

View File

@ -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)

View File

@ -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({

View File

@ -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)

View File

@ -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)

View File

@ -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),

View File

@ -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, {

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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} />

View File

@ -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>

View File

@ -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}

View File

@ -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={{

View File

@ -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

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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 (
<> <>

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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"