Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
9b6fda0c31
|
@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people.
|
|||
Reduce the number of easily preventable deaths worldwide.
|
||||
Work towards sustainable, systemic change.`,
|
||||
},
|
||||
{
|
||||
name: 'YIMBY Law',
|
||||
website: 'https://www.yimbylaw.org/',
|
||||
photo: 'https://i.imgur.com/zlzp21Z.png',
|
||||
preview:
|
||||
'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.',
|
||||
description: `
|
||||
YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply.
|
||||
|
||||
If you would like to support our work, you can do so by getting involved or by donating.`,
|
||||
},
|
||||
{
|
||||
name: 'CaRLA',
|
||||
website: 'https://carlaef.org/',
|
||||
photo: 'https://i.imgur.com/IsNVTOY.png',
|
||||
preview:
|
||||
'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.',
|
||||
description: `
|
||||
The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.
|
||||
|
||||
CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage.
|
||||
|
||||
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export type AnyCommentType = OnContract | OnGroup
|
||||
|
||||
// Currently, comments are created after the bet, not atomically with the bet.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
export type Comment = {
|
||||
export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||
id: string
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
betId?: string
|
||||
answerOutcome?: string
|
||||
replyToCommentId?: string
|
||||
userId: string
|
||||
|
||||
|
@ -20,6 +18,21 @@ export type Comment = {
|
|||
userName: string
|
||||
userUsername: string
|
||||
userAvatarUrl?: string
|
||||
contractSlug?: string
|
||||
contractQuestion?: string
|
||||
} & T
|
||||
|
||||
type OnContract = {
|
||||
commentType: 'contract'
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
answerOutcome?: string
|
||||
betId?: string
|
||||
}
|
||||
|
||||
type OnGroup = {
|
||||
commentType: 'group'
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type ContractComment = Comment<OnContract>
|
||||
export type GroupComment = Comment<OnGroup>
|
||||
|
|
151
common/contract-details.ts
Normal file
151
common/contract-details.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { Challenge } from './challenge'
|
||||
import { BinaryContract, Contract } from './contract'
|
||||
import { getFormattedMappedValue } from './pseudo-numeric'
|
||||
import { getProbability } from './calculate'
|
||||
import { richTextToString } from './util/parse'
|
||||
import { getCpmmProbability } from './calculate-cpmm'
|
||||
import { getDpmProbability } from './calculate-dpm'
|
||||
import { formatMoney, formatPercent } from './util/format'
|
||||
|
||||
export function contractMetrics(contract: Contract) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dayjs = require('dayjs')
|
||||
const { createdTime, resolutionTime, isResolved } = contract
|
||||
|
||||
const createdDate = dayjs(createdTime).format('MMM D')
|
||||
|
||||
const resolvedDate = isResolved
|
||||
? dayjs(resolutionTime).format('MMM D')
|
||||
: undefined
|
||||
|
||||
const volumeLabel = `${formatMoney(contract.volume)} bet`
|
||||
|
||||
return { volumeLabel, createdDate, resolvedDate }
|
||||
}
|
||||
|
||||
// String version of the above, to send to the OpenGraph image generator
|
||||
export function contractTextDetails(contract: Contract) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dayjs = require('dayjs')
|
||||
const { closeTime, tags } = contract
|
||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||
|
||||
const hashtags = tags.map((tag) => `#${tag}`)
|
||||
|
||||
return (
|
||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||
(closeTime
|
||||
? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs(
|
||||
closeTime
|
||||
).format('MMM D, h:mma')}`
|
||||
: '') +
|
||||
` • ${volumeLabel}` +
|
||||
(hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getBinaryProb(contract: BinaryContract) {
|
||||
const { pool, resolutionProbability, mechanism } = contract
|
||||
|
||||
return (
|
||||
resolutionProbability ??
|
||||
(mechanism === 'cpmm-1'
|
||||
? getCpmmProbability(pool, contract.p)
|
||||
: getDpmProbability(contract.totalShares))
|
||||
)
|
||||
}
|
||||
|
||||
export const getOpenGraphProps = (contract: Contract) => {
|
||||
const {
|
||||
resolution,
|
||||
question,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
outcomeType,
|
||||
creatorAvatarUrl,
|
||||
description: desc,
|
||||
} = contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY'
|
||||
? formatPercent(getBinaryProb(contract))
|
||||
: undefined
|
||||
|
||||
const numericValue =
|
||||
outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getFormattedMappedValue(contract)(getProbability(contract))
|
||||
: undefined
|
||||
|
||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${stringDesc}`
|
||||
: probPercent
|
||||
? `${probPercent} chance. ${stringDesc}`
|
||||
: stringDesc
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
description,
|
||||
numericValue,
|
||||
}
|
||||
}
|
||||
|
||||
export type OgCardProps = {
|
||||
question: string
|
||||
probability?: string
|
||||
metadata: string
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl?: string
|
||||
numericValue?: string
|
||||
}
|
||||
|
||||
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||
const {
|
||||
creatorAmount,
|
||||
acceptances,
|
||||
acceptorAmount,
|
||||
creatorOutcome,
|
||||
acceptorOutcome,
|
||||
} = challenge || {}
|
||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||
|
||||
const probabilityParam =
|
||||
props.probability === undefined
|
||||
? ''
|
||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||
|
||||
const numericValueParam =
|
||||
props.numericValue === undefined
|
||||
? ''
|
||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
||||
|
||||
const creatorAvatarUrlParam =
|
||||
props.creatorAvatarUrl === undefined
|
||||
? ''
|
||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
||||
|
||||
const challengeUrlParams = challenge
|
||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
|
||||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||
: ''
|
||||
|
||||
// URL encode each of the props, then add them as query params
|
||||
return (
|
||||
`https://manifold-og-image.vercel.app/m.png` +
|
||||
`?question=${encodeURIComponent(props.question)}` +
|
||||
probabilityParam +
|
||||
numericValueParam +
|
||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||
creatorAvatarUrlParam +
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||
challengeUrlParams
|
||||
)
|
||||
}
|
|
@ -31,7 +31,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
description: string | JSONContent // More info about what the contract is about
|
||||
tags: string[]
|
||||
lowercaseTags: string[]
|
||||
visibility: 'public' | 'unlisted'
|
||||
visibility: visibility
|
||||
|
||||
createdTime: number // Milliseconds since epoch
|
||||
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 CPMM_MIN_POOL_QTY = 0.01
|
||||
|
||||
export type visibility = 'public' | 'unlisted'
|
||||
export const VISIBILITIES = ['public', 'unlisted'] as const
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
Numeric,
|
||||
outcomeType,
|
||||
PseudoNumeric,
|
||||
visibility,
|
||||
} from './contract'
|
||||
import { User } from './user'
|
||||
import { parseTags, richTextToString } from './util/parse'
|
||||
|
@ -34,7 +35,8 @@ export function getNewContract(
|
|||
isLogScale: boolean,
|
||||
|
||||
// for multiple choice
|
||||
answers: string[]
|
||||
answers: string[],
|
||||
visibility: visibility
|
||||
) {
|
||||
const tags = parseTags(
|
||||
[
|
||||
|
@ -70,7 +72,7 @@ export function getNewContract(
|
|||
description,
|
||||
tags,
|
||||
lowercaseTags,
|
||||
visibility: 'public',
|
||||
visibility,
|
||||
isResolved: false,
|
||||
createdTime: Date.now(),
|
||||
closeTime,
|
||||
|
|
|
@ -38,6 +38,7 @@ export type notification_source_types =
|
|||
| 'user'
|
||||
| 'bonus'
|
||||
| 'challenge'
|
||||
| 'betting_streak_bonus'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -66,3 +67,4 @@ export type notification_reason_types =
|
|||
| 'bet_fill'
|
||||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
|
|
|
@ -4,3 +4,6 @@ export const NUMERIC_FIXED_VAR = 0.005
|
|||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT = 5
|
||||
export const BETTING_STREAK_BONUS_MAX = 100
|
||||
export const BETTING_STREAK_RESET_HOUR = 0
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { ClickEvent } from './tracking'
|
||||
import { filterDefined } from './util/array'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
export const MAX_FEED_CONTRACTS = 75
|
||||
|
||||
export const getRecommendedContracts = (
|
||||
contractsById: { [contractId: string]: Contract },
|
||||
yourBetOnContractIds: string[]
|
||||
) => {
|
||||
const contracts = Object.values(contractsById)
|
||||
const yourContracts = filterDefined(
|
||||
yourBetOnContractIds.map((contractId) => contractsById[contractId])
|
||||
)
|
||||
|
||||
const yourContractIds = new Set(yourContracts.map((c) => c.id))
|
||||
const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id))
|
||||
|
||||
const yourWordFrequency = contractsToWordFrequency(yourContracts)
|
||||
const otherWordFrequency = contractsToWordFrequency(notYourContracts)
|
||||
const words = union(
|
||||
Object.keys(yourWordFrequency),
|
||||
Object.keys(otherWordFrequency)
|
||||
)
|
||||
|
||||
const yourWeightedFrequency = Object.fromEntries(
|
||||
words.map((word) => {
|
||||
const [yourFreq, otherFreq] = [
|
||||
yourWordFrequency[word] ?? 0,
|
||||
otherWordFrequency[word] ?? 0,
|
||||
]
|
||||
|
||||
const score = yourFreq / (yourFreq + otherFreq + 0.0001)
|
||||
|
||||
return [word, score]
|
||||
})
|
||||
)
|
||||
|
||||
// console.log(
|
||||
// 'your weighted frequency',
|
||||
// _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq)
|
||||
// )
|
||||
|
||||
const scoredContracts = contracts.map((contract) => {
|
||||
const wordFrequency = contractToWordFrequency(contract)
|
||||
|
||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
||||
const wordFreq = wordFrequency[word] ?? 0
|
||||
const weight = yourWeightedFrequency[word] ?? 0
|
||||
return wordFreq * weight
|
||||
})
|
||||
|
||||
return {
|
||||
contract,
|
||||
score,
|
||||
}
|
||||
})
|
||||
|
||||
return sortBy(scoredContracts, (scored) => -scored.score).map(
|
||||
(scored) => scored.contract
|
||||
)
|
||||
}
|
||||
|
||||
const contractToText = (contract: Contract) => {
|
||||
const { description, question, tags, creatorUsername } = contract
|
||||
return `${creatorUsername} ${question} ${tags.join(' ')} ${description}`
|
||||
}
|
||||
|
||||
const MAX_CHARS_IN_WORD = 100
|
||||
|
||||
const getWordsCount = (text: string) => {
|
||||
const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase()
|
||||
const words = normalizedText
|
||||
.split(' ')
|
||||
.filter((word) => word)
|
||||
.filter((word) => word.length <= MAX_CHARS_IN_WORD)
|
||||
|
||||
const counts: { [word: string]: number } = {}
|
||||
for (const word of words) {
|
||||
if (counts[word]) counts[word]++
|
||||
else counts[word] = 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const toFrequency = (counts: { [word: string]: number }) => {
|
||||
const total = sum(Object.values(counts))
|
||||
return mapValues(counts, (count) => count / total)
|
||||
}
|
||||
|
||||
const contractToWordFrequency = (contract: Contract) =>
|
||||
toFrequency(getWordsCount(contractToText(contract)))
|
||||
|
||||
const contractsToWordFrequency = (contracts: Contract[]) => {
|
||||
const frequencySum = contracts
|
||||
.map(contractToWordFrequency)
|
||||
.reduce(addObjects, {})
|
||||
|
||||
return toFrequency(frequencySum)
|
||||
}
|
||||
|
||||
export const getWordScores = (
|
||||
contracts: Contract[],
|
||||
contractViewCounts: { [contractId: string]: number },
|
||||
clicks: ClickEvent[],
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const contractClicks = groupBy(clicks, (click) => click.contractId)
|
||||
const contractBets = groupBy(bets, (bet) => bet.contractId)
|
||||
|
||||
const yourContracts = contracts.filter(
|
||||
(c) =>
|
||||
contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id]
|
||||
)
|
||||
const yourTfIdf = calculateContractTfIdf(yourContracts)
|
||||
|
||||
const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
|
||||
const viewCount = contractViewCounts[contractId] ?? 0
|
||||
const clickCount = contractClicks[contractId]?.length ?? 0
|
||||
const betCount = contractBets[contractId]?.length ?? 0
|
||||
|
||||
const factor =
|
||||
-1 * Math.log(viewCount + 1) +
|
||||
10 * Math.log(betCount + clickCount / 4 + 1)
|
||||
|
||||
return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
|
||||
})
|
||||
|
||||
const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
|
||||
const minScore = Math.min(...Object.values(wordScores))
|
||||
const maxScore = Math.max(...Object.values(wordScores))
|
||||
const normalizedWordScores = mapValues(
|
||||
wordScores,
|
||||
(score) => (score - minScore) / (maxScore - minScore)
|
||||
)
|
||||
|
||||
// console.log(
|
||||
// 'your word scores',
|
||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100),
|
||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100)
|
||||
// )
|
||||
|
||||
return normalizedWordScores
|
||||
}
|
||||
|
||||
export function getContractScore(
|
||||
contract: Contract,
|
||||
wordScores: { [word: string]: number }
|
||||
) {
|
||||
if (Object.keys(wordScores).length === 0) return 1
|
||||
|
||||
const wordFrequency = contractToWordFrequency(contract)
|
||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
||||
const wordFreq = wordFrequency[word] ?? 0
|
||||
const weight = wordScores[word] ?? 0
|
||||
return wordFreq * weight
|
||||
})
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF):
|
||||
// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736
|
||||
function calculateContractTfIdf(contracts: Contract[]) {
|
||||
const contractFreq = contracts.map((c) => contractToWordFrequency(c))
|
||||
const contractWords = contractFreq.map((freq) => Object.keys(freq))
|
||||
|
||||
const wordsCount: { [word: string]: number } = {}
|
||||
for (const words of contractWords) {
|
||||
for (const word of words) {
|
||||
wordsCount[word] = (wordsCount[word] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const wordIdf = mapValues(wordsCount, (count) =>
|
||||
Math.log(contracts.length / count)
|
||||
)
|
||||
const contractWordsTfIdf = contractFreq.map((wordFreq) =>
|
||||
mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
|
||||
)
|
||||
return Object.fromEntries(
|
||||
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
|
||||
)
|
||||
}
|
|
@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
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
|
||||
data?: { [key: string]: any }
|
||||
|
@ -57,7 +63,7 @@ type Referral = {
|
|||
type Bonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
|
|
|
@ -41,6 +41,8 @@ export type User = {
|
|||
referredByGroupId?: string
|
||||
lastPingTime?: number
|
||||
shouldShowWelcome?: boolean
|
||||
lastBetTime?: number
|
||||
currentBettingStreak?: number
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
|
@ -57,6 +59,7 @@ export type PrivateUser = {
|
|||
unsubscribedFromCommentEmails?: boolean
|
||||
unsubscribedFromAnswerEmails?: boolean
|
||||
unsubscribedFromGenericEmails?: boolean
|
||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
|
|
|
@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||
- [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
|
||||
- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API
|
||||
|
||||
## Bots
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ service cloud.firestore {
|
|||
allow read: if userId == request.auth.uid || isAdmin();
|
||||
allow update: if (userId == request.auth.uid || isAdmin())
|
||||
&& 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} {
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"lodash": "4.17.21",
|
||||
"mailgun-js": "0.22.0",
|
||||
"module-alias": "2.2.2",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"stripe": "8.194.0",
|
||||
"zod": "3.17.2"
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet'
|
|||
import { createChallengeAcceptedNotification } from './create-notification'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => {
|
|||
return yourNewBetDoc
|
||||
})
|
||||
|
||||
await redeemShares(auth.uid, contractId)
|
||||
|
||||
return { betId: result.id }
|
||||
})
|
||||
|
|
|
@ -23,13 +23,8 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
|||
type KeyCredentials = { kind: 'key'; data: string }
|
||||
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> => {
|
||||
const auth = admin.auth()
|
||||
const authHeader = req.get('Authorization')
|
||||
if (!authHeader) {
|
||||
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> => {
|
||||
const firestore = admin.firestore()
|
||||
const privateUsers = firestore.collection('private-users')
|
||||
switch (creds.kind) {
|
||||
case 'jwt': {
|
||||
if (typeof creds.data.user_id !== 'string') {
|
||||
|
@ -70,7 +67,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
|||
if (privateUserQ.empty) {
|
||||
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 } }
|
||||
}
|
||||
default:
|
||||
|
|
|
@ -21,6 +21,7 @@ const bodySchema = z.object({
|
|||
})
|
||||
|
||||
export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||
const firestore = admin.firestore()
|
||||
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||
bodySchema,
|
||||
req.body
|
||||
|
@ -67,7 +68,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
|||
return { status: 'success', group: group }
|
||||
})
|
||||
|
||||
const getSlug = async (name: string) => {
|
||||
export const getSlug = async (name: string) => {
|
||||
const proposedSlug = slugify(name)
|
||||
|
||||
const preexistingGroup = await getGroupFromSlug(proposedSlug)
|
||||
|
@ -75,9 +76,8 @@ const getSlug = async (name: string) => {
|
|||
return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function getGroupFromSlug(slug: string) {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
.collection('groups')
|
||||
.where('slug', '==', slug)
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
OUTCOME_TYPES,
|
||||
VISIBILITIES,
|
||||
} from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
@ -67,6 +68,7 @@ const bodySchema = z.object({
|
|||
),
|
||||
outcomeType: z.enum(OUTCOME_TYPES),
|
||||
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
|
||||
visibility: z.enum(VISIBILITIES).optional(),
|
||||
})
|
||||
|
||||
const binarySchema = z.object({
|
||||
|
@ -88,8 +90,15 @@ const multipleChoiceSchema = z.object({
|
|||
})
|
||||
|
||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||
validate(bodySchema, req.body)
|
||||
const {
|
||||
question,
|
||||
description,
|
||||
tags,
|
||||
closeTime,
|
||||
outcomeType,
|
||||
groupId,
|
||||
visibility = 'public',
|
||||
} = validate(bodySchema, req.body)
|
||||
|
||||
let min, max, initialProb, isLogScale, answers
|
||||
|
||||
|
@ -194,7 +203,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
min ?? 0,
|
||||
max ?? 0,
|
||||
isLogScale ?? false,
|
||||
answers ?? []
|
||||
answers ?? [],
|
||||
visibility
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
|
@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async (
|
|||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
export const createBettingStreakBonusNotification = async (
|
||||
user: User,
|
||||
txnId: string,
|
||||
bet: Bet,
|
||||
contract: Contract,
|
||||
amount: number,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${user.id}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: user.id,
|
||||
reason: 'betting_streak_incremented',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: txnId,
|
||||
sourceType: 'betting_streak_bonus',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: user.name,
|
||||
sourceUserUsername: user.username,
|
||||
sourceUserAvatarUrl: user.avatarUrl,
|
||||
sourceText: amount.toString(),
|
||||
sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`,
|
||||
sourceTitle: 'Betting Streak Bonus',
|
||||
// Perhaps not necessary, but just in case
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ import {
|
|||
cleanDisplayName,
|
||||
cleanUsername,
|
||||
} from '../../common/util/clean-username'
|
||||
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
||||
import { isWhitelisted } from '../../common/envs/constants'
|
||||
import {
|
||||
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 addUserToDefaultGroups(user)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await sendPersonalFollowupEmail(user, privateUser)
|
||||
|
||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||
|
||||
return { user, privateUser }
|
||||
|
|
|
@ -103,82 +103,7 @@
|
|||
</head>
|
||||
|
||||
<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]-->
|
||||
<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/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;
|
||||
|
@ -189,6 +114,15 @@
|
|||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<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>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
|
@ -252,25 +186,55 @@
|
|||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">Congrats on creating your first market on <a class="link-build-content"
|
||||
style="color: #55575d" target="_blank"
|
||||
href="https://manifold.markets">Manifold</a>!</span>
|
||||
">Did you know you create your own prediction market on <a class="link-build-content"
|
||||
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for
|
||||
any question you care about?</span>
|
||||
</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">
|
||||
<span style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">Whether it's current events like <a class="link-build-content" style="color: #55575d"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/SG/will-elon-musk-buy-twitter-this-yea">Musk buying
|
||||
Twitter</a> or <a class="link-build-content" style="color: #55575d" target="_blank"
|
||||
href="https://manifold.markets/NathanpmYoung/will-biden-be-the-2024-democratic-n">2024
|
||||
elections</a> or personal matters
|
||||
like <a class="link-build-content" style="color: #55575d" target="_blank"
|
||||
href="https://manifold.markets/dreev/which-book-will-i-like-best">book
|
||||
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>
|
||||
">The following is a
|
||||
short guide to creating markets.</span>
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
|
||||
<table cellspacing="0" cellpadding="0" align="center">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="https://manifold.markets/create" target="_blank"
|
||||
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
Create a market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
<span style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
|
@ -313,7 +277,7 @@
|
|||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
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.
|
||||
Also, consider making your own groups and
|
||||
inviting friends/interested communities to
|
||||
|
@ -324,7 +288,7 @@
|
|||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
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"
|
||||
href="https://manifold.markets/referrals"><span style="
|
||||
color: #55575d;
|
||||
|
@ -335,85 +299,34 @@
|
|||
referral bonus</u></span></a> if you get new users to sign up!</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
<span style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
"><b>Examples of markets you should
|
||||
emulate! </b></span>
|
||||
</p>
|
||||
<ul>
|
||||
<li style="line-height: 23px">
|
||||
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"><u>This complex market</u></span></a><span style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">
|
||||
about the project I am working on.</span>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"><u>This simple market</u></span></a><span style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">
|
||||
about Manifold's weekly active
|
||||
users.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
<span style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">Why not </span>
|
||||
|
||||
|
||||
|
||||
<a class="link-build-content" style="color: inherit; text-decoration: none" target="_blank"
|
||||
href="https://manifold.markets/create"><span style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
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,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">
|
||||
while it is still fresh on your mind?
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
|
||||
</p>
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0" data-testid="3Q8BP69fq">
|
||||
<span style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
|
@ -445,6 +358,83 @@
|
|||
</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>
|
||||
</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%">
|
||||
|
|
476
functions/src/email-templates/interesting-markets.html
Normal file
476
functions/src/email-templates/interesting-markets.html
Normal file
|
@ -0,0 +1,476 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Interesting markets on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Here is a selection of markets on Manifold you might find
|
||||
interesting!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question1Link}}">
|
||||
<img alt="{{question1Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question1ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question1Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question2Link}}">
|
||||
<img alt="{{question2Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question2ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question2Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question3Link}}">
|
||||
<img alt="{{question3Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question3ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question3Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question4Link}}">
|
||||
<img alt="{{question4Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question4ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question4Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question5Link}}">
|
||||
<img alt="{{question5Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question5ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question5Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{question6Link}}">
|
||||
<img alt="{{question6Title}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{question6ImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{question6Link}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeLink}}"
|
||||
style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -107,19 +107,12 @@
|
|||
width="100%">
|
||||
<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/urx/sjtz.gif"
|
||||
<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;"
|
||||
width="550"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
title="" width="550">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -175,9 +168,9 @@
|
|||
<td>
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<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
|
||||
</a>
|
||||
</td>
|
||||
|
@ -225,22 +218,12 @@
|
|||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
|
||||
chat</u></span></a></span></li>
|
||||
</ul>
|
||||
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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
|
||||
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;"
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import * as dayjs from 'dayjs'
|
||||
|
||||
import { DOMAIN } from '../../common/envs/constants'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
@ -20,6 +18,7 @@ import { sendTemplateEmail, sendTextEmail } from './send-email'
|
|||
import { getPrivateUser, getUser } from './utils'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||
|
||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
||||
|
||||
|
@ -169,7 +168,8 @@ export const sendWelcomeEmail = async (
|
|||
|
||||
export const sendPersonalFollowupEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
privateUser: PrivateUser,
|
||||
sendTime: string
|
||||
) => {
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
|
@ -191,8 +191,6 @@ Cofounder of Manifold Markets
|
|||
https://manifold.markets
|
||||
`
|
||||
|
||||
const sendTime = dayjs().add(4, 'hours').toString()
|
||||
|
||||
await sendTextEmail(
|
||||
privateUser.email,
|
||||
'How are you finding Manifold?',
|
||||
|
@ -238,7 +236,8 @@ export const sendOneWeekBonusEmail = async (
|
|||
|
||||
export const sendCreatorGuideEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
privateUser: PrivateUser,
|
||||
sendTime: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
|
@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
|
|||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Market creation guide',
|
||||
'Create your own prediction market',
|
||||
'creating-market',
|
||||
{
|
||||
name: firstName,
|
||||
|
@ -263,6 +262,7 @@ export const sendCreatorGuideEmail = async (
|
|||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
'o:deliverytime': sendTime,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -460,3 +460,61 @@ export const sendNewAnswerEmail = async (
|
|||
{ from }
|
||||
)
|
||||
}
|
||||
|
||||
export const sendInterestingMarketsEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser,
|
||||
contractsToSend: Contract[],
|
||||
deliveryTime?: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
privateUser?.unsubscribedFromWeeklyTrendingEmails
|
||||
)
|
||||
return
|
||||
|
||||
const emailType = 'weekly-trending'
|
||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
`${contractsToSend[0].question} & 5 more interesting markets on Manifold`,
|
||||
'interesting-markets',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink: unsubscribeUrl,
|
||||
|
||||
question1Title: contractsToSend[0].question,
|
||||
question1Link: contractUrl(contractsToSend[0]),
|
||||
question1ImgSrc: imageSourceUrl(contractsToSend[0]),
|
||||
question2Title: contractsToSend[1].question,
|
||||
question2Link: contractUrl(contractsToSend[1]),
|
||||
question2ImgSrc: imageSourceUrl(contractsToSend[1]),
|
||||
question3Title: contractsToSend[2].question,
|
||||
question3Link: contractUrl(contractsToSend[2]),
|
||||
question3ImgSrc: imageSourceUrl(contractsToSend[2]),
|
||||
question4Title: contractsToSend[3].question,
|
||||
question4Link: contractUrl(contractsToSend[3]),
|
||||
question4ImgSrc: imageSourceUrl(contractsToSend[3]),
|
||||
question5Title: contractsToSend[4].question,
|
||||
question5Link: contractUrl(contractsToSend[4]),
|
||||
question5ImgSrc: imageSourceUrl(contractsToSend[4]),
|
||||
question6Title: contractsToSend[5].question,
|
||||
question6Link: contractUrl(contractsToSend[5]),
|
||||
question6ImgSrc: imageSourceUrl(contractsToSend[5]),
|
||||
},
|
||||
deliveryTime ? { 'o:deliverytime': deliveryTime } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
function imageSourceUrl(contract: Contract) {
|
||||
return buildCardUrl(getOpenGraphProps(contract))
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { EndpointDefinition } from './api'
|
|||
admin.initializeApp()
|
||||
|
||||
// v1
|
||||
export * from './on-create-user'
|
||||
export * from './on-create-bet'
|
||||
export * from './on-create-comment-on-contract'
|
||||
export * from './on-view'
|
||||
|
@ -25,6 +26,8 @@ export * from './on-create-comment-on-group'
|
|||
export * from './on-create-txn'
|
||||
export * from './on-delete-group'
|
||||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -37,7 +40,7 @@ export * from './cancel-bet'
|
|||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-contract'
|
||||
export * from './create-market'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
|
@ -56,7 +59,7 @@ import { cancelbet } from './cancel-bet'
|
|||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
|
|
|
@ -3,15 +3,21 @@ import * as admin from 'firebase-admin'
|
|||
import { keyBy, uniq } from 'lodash'
|
||||
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
import { getContract, getUser, getValues, isProd, log } from './utils'
|
||||
import { getUser, getValues, isProd, log } from './utils'
|
||||
import {
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
createNotification,
|
||||
} from './create-notification'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { Contract } from '../../common/contract'
|
||||
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 {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -38,37 +44,99 @@ export const onCreateBet = functions.firestore
|
|||
.doc(contractId)
|
||||
.update({ lastBetTime, lastUpdatedTime: Date.now() })
|
||||
|
||||
await notifyFills(bet, contractId, eventId)
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(
|
||||
contractId,
|
||||
eventId,
|
||||
bet.userId
|
||||
)
|
||||
})
|
||||
|
||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
contractId: string,
|
||||
eventId: 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
|
||||
}
|
||||
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 (
|
||||
contract: Contract,
|
||||
eventId: string,
|
||||
bettorId: string
|
||||
) => {
|
||||
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||
|
||||
if (!previousUniqueBettorIds) {
|
||||
const contractBets = (
|
||||
await firestore.collection(`contracts/${contractId}/bets`).get()
|
||||
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
||||
).docs.map((doc) => doc.data() as Bet)
|
||||
|
||||
if (contractBets.length === 0) {
|
||||
log(`No bets for contract ${contractId}`)
|
||||
log(`No bets for contract ${contract.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -86,7 +154,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
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,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
})
|
||||
|
@ -97,7 +165,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
|
||||
// Create combined txn for all new unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contractId,
|
||||
contractId: contract.id,
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
}
|
||||
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
|
||||
|
||||
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 matchedBets = (
|
||||
await Promise.all(
|
||||
|
@ -180,3 +248,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getTodaysBettingStreakResetTime = () => {
|
||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { compact, uniq } from 'lodash'
|
||||
import { getContract, getUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { ContractComment } from '../../common/comment'
|
||||
import { sendNewCommentEmail } from './emails'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions
|
|||
contractQuestion: contract.question,
|
||||
})
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const comment = change.data() as ContractComment
|
||||
const lastCommentTime = comment.createdTime
|
||||
|
||||
const commentCreator = await getUser(comment.userId)
|
||||
|
@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions
|
|||
: undefined
|
||||
}
|
||||
|
||||
const comments = await getValues<Comment>(
|
||||
const comments = await getValues<ContractComment>(
|
||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||
)
|
||||
const relatedSourceType = comment.replyToCommentId
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { GroupComment } from '../../common/comment'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { User } from '../../common/user'
|
||||
|
@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore
|
|||
groupId: string
|
||||
}
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const comment = change.data() as GroupComment
|
||||
const creatorSnapshot = await firestore
|
||||
.collection('users')
|
||||
.doc(comment.userId)
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
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 { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { User } from 'common/user'
|
||||
import { sendCreatorGuideEmail } from './emails'
|
||||
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -31,23 +28,4 @@ export const onCreateContract = functions
|
|||
richTextToString(desc),
|
||||
{ contract, recipients: mentioned }
|
||||
)
|
||||
|
||||
await sendGuideEmail(contractCreator)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const sendGuideEmail = async (contractCreator: User) => {
|
||||
const query = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', contractCreator.id)
|
||||
.limit(2)
|
||||
.get()
|
||||
|
||||
if (query.size >= 2) return
|
||||
|
||||
const privateUser = await getPrivateUser(contractCreator.id)
|
||||
if (!privateUser) return
|
||||
|
||||
await sendCreatorGuideEmail(contractCreator, privateUser)
|
||||
}
|
||||
|
|
45
functions/src/on-create-user.ts
Normal file
45
functions/src/on-create-user.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as dayjs from 'dayjs'
|
||||
import * as utc from 'dayjs/plugin/utc'
|
||||
dayjs.extend(utc)
|
||||
|
||||
import { getPrivateUser } from './utils'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
sendCreatorGuideEmail,
|
||||
sendInterestingMarketsEmail,
|
||||
sendPersonalFollowupEmail,
|
||||
sendWelcomeEmail,
|
||||
} from './emails'
|
||||
import { getTrendingContracts } from './weekly-markets-emails'
|
||||
|
||||
export const onCreateUser = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('users/{userId}')
|
||||
.onCreate(async (snapshot) => {
|
||||
const user = snapshot.data() as User
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) return
|
||||
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
|
||||
const guideSendTime = dayjs().add(28, 'hours').toString()
|
||||
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
|
||||
|
||||
const followupSendTime = dayjs().add(48, 'hours').toString()
|
||||
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
|
||||
|
||||
// skip email if weekly email is about to go out
|
||||
const day = dayjs().utc().day()
|
||||
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
|
||||
|
||||
const contracts = await getTrendingContracts()
|
||||
const marketsSendTime = dayjs().add(24, 'hours').toString()
|
||||
|
||||
await sendInterestingMarketsEmail(
|
||||
user,
|
||||
privateUser,
|
||||
contracts,
|
||||
marketsSendTime
|
||||
)
|
||||
})
|
39
functions/src/reset-betting-streaks.ts
Normal file
39
functions/src/reset-betting-streaks.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
|
||||
|
||||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const resetBettingStreaksForUsers = functions.pubsub
|
||||
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||
.timeZone('utc')
|
||||
.onRun(async () => {
|
||||
await resetBettingStreaksInternal()
|
||||
})
|
||||
|
||||
const resetBettingStreaksInternal = async () => {
|
||||
const usersSnap = await firestore.collection('users').get()
|
||||
|
||||
const users = usersSnap.docs.map((doc) => doc.data() as User)
|
||||
|
||||
for (const user of users) {
|
||||
await resetBettingStreakForUser(user)
|
||||
}
|
||||
}
|
||||
|
||||
const resetBettingStreakForUser = async (user: User) => {
|
||||
const betStreakResetTime = Date.now() - DAY_MS
|
||||
// if they made a bet within the last day, don't reset their streak
|
||||
if (
|
||||
(user.lastBetTime ?? 0 > betStreakResetTime) ||
|
||||
!user.currentBettingStreak ||
|
||||
user.currentBettingStreak === 0
|
||||
)
|
||||
return
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
currentBettingStreak: 0,
|
||||
})
|
||||
}
|
31
functions/src/scripts/backfill-comment-types.ts
Normal file
31
functions/src/scripts/backfill-comment-types.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Comment types were introduced in August 2022.
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
|
||||
if (require.main === module) {
|
||||
const app = initAdmin()
|
||||
const firestore = app.firestore()
|
||||
const commentsRef = firestore.collectionGroup('comments')
|
||||
commentsRef.get().then(async (commentsSnaps) => {
|
||||
log(`Loaded ${commentsSnaps.size} comments.`)
|
||||
const needsFilling = commentsSnaps.docs.filter((ct) => {
|
||||
return !('commentType' in ct.data())
|
||||
})
|
||||
log(`Found ${needsFilling.length} comments to update.`)
|
||||
const updates = needsFilling.map((d) => {
|
||||
const comment = d.data()
|
||||
const fields: { [k: string]: unknown } = {}
|
||||
if (comment.contractId != null && comment.groupId == null) {
|
||||
fields.commentType = 'contract'
|
||||
} else if (comment.groupId != null && comment.contractId == null) {
|
||||
fields.commentType = 'group'
|
||||
} else {
|
||||
log(`Invalid comment ${comment}; not touching it.`)
|
||||
}
|
||||
return { doc: d.ref, fields, info: comment }
|
||||
})
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all comments.`)
|
||||
})
|
||||
}
|
66
functions/src/scripts/convert-tag-to-group.ts
Normal file
66
functions/src/scripts/convert-tag-to-group.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Takes a tag and makes a new group with all the contracts in it.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import { isProd, log } from '../utils'
|
||||
import { getSlug } from '../create-group'
|
||||
import { Group } from '../../../common/group'
|
||||
|
||||
const getTaggedContractIds = async (tag: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const results = await firestore
|
||||
.collection('contracts')
|
||||
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
||||
.get()
|
||||
return results.docs.map((d) => d.id)
|
||||
}
|
||||
|
||||
const createGroup = async (
|
||||
name: string,
|
||||
about: string,
|
||||
contractIds: string[]
|
||||
) => {
|
||||
const firestore = admin.firestore()
|
||||
const creatorId = isProd()
|
||||
? 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
|
||||
: '94YYTk1AFWfbWMpfYcvnnwI1veP2'
|
||||
|
||||
const slug = await getSlug(name)
|
||||
const groupRef = firestore.collection('groups').doc()
|
||||
const now = Date.now()
|
||||
const group: Group = {
|
||||
id: groupRef.id,
|
||||
creatorId,
|
||||
slug,
|
||||
name,
|
||||
about,
|
||||
createdTime: now,
|
||||
mostRecentActivityTime: now,
|
||||
contractIds: contractIds,
|
||||
anyoneCanJoin: true,
|
||||
memberIds: [],
|
||||
}
|
||||
return await groupRef.create(group)
|
||||
}
|
||||
|
||||
const convertTagToGroup = async (tag: string, groupName: string) => {
|
||||
log(`Looking up contract IDs with tag ${tag}...`)
|
||||
const contractIds = await getTaggedContractIds(tag)
|
||||
log(`${contractIds.length} contracts found.`)
|
||||
if (contractIds.length > 0) {
|
||||
log(`Creating group ${groupName}...`)
|
||||
const about = `Contracts that used to be tagged ${tag}.`
|
||||
const result = await createGroup(groupName, about, contractIds)
|
||||
log(`Done. Group: `, result)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
initAdmin()
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length != 2) {
|
||||
console.log('Usage: convert-tag-to-group [tag] [group-name]')
|
||||
} else {
|
||||
convertTagToGroup(args[0], args[1]).catch((e) => console.error(e))
|
||||
}
|
||||
}
|
29
functions/src/scripts/unlist-contracts.ts
Normal file
29
functions/src/scripts/unlist-contracts.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { Contract } from '../../../common/contract'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function unlistContracts() {
|
||||
console.log('Updating some contracts to be unlisted')
|
||||
|
||||
const snapshot = await firestore
|
||||
.collection('contracts')
|
||||
.where('groupSlugs', 'array-contains', 'fantasy-football-stock-exchange')
|
||||
.get()
|
||||
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
||||
|
||||
console.log('Loaded', contracts.length, 'contracts')
|
||||
|
||||
for (const contract of contracts) {
|
||||
const contractRef = firestore.doc(`contracts/${contract.id}`)
|
||||
|
||||
console.log('Updating', contract.question)
|
||||
await contractRef.update({ visibility: 'unlisted' })
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) unlistContracts().then(() => process.exit())
|
|
@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet'
|
|||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
|
|
|
@ -21,6 +21,7 @@ export const unsubscribe: EndpointDefinition = {
|
|||
'market-comment',
|
||||
'market-answer',
|
||||
'generic',
|
||||
'weekly-trending',
|
||||
].includes(type)
|
||||
) {
|
||||
res.status(400).send('Invalid type parameter.')
|
||||
|
@ -49,6 +50,9 @@ export const unsubscribe: EndpointDefinition = {
|
|||
...(type === 'generic' && {
|
||||
unsubscribedFromGenericEmails: true,
|
||||
}),
|
||||
...(type === 'weekly-trending' && {
|
||||
unsubscribedFromWeeklyTrendingEmails: true,
|
||||
}),
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
|
|
|
@ -88,6 +88,12 @@ export const getPrivateUser = (userId: string) => {
|
|||
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) => {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
|
|
81
functions/src/weekly-markets-emails.ts
Normal file
81
functions/src/weekly-markets-emails.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getAllPrivateUsers, getUser, getValues, log } from './utils'
|
||||
import { sendInterestingMarketsEmail } from './emails'
|
||||
import { createRNG, shuffle } from '../../common/util/random'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
|
||||
export const weeklyMarketsEmails = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
// every Monday at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('0 19 * * 1')
|
||||
.timeZone('utc')
|
||||
.onRun(async () => {
|
||||
await sendTrendingMarketsEmailsToAllUsers()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function getTrendingContracts() {
|
||||
return await getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('isResolved', '==', false)
|
||||
.where('visibility', '==', 'public')
|
||||
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||
// so have to filter for closed contracts separately
|
||||
.orderBy('popularityScore', 'desc')
|
||||
// might as well go big and do a quick filter for closed ones later
|
||||
.limit(500)
|
||||
)
|
||||
}
|
||||
|
||||
async function sendTrendingMarketsEmailsToAllUsers() {
|
||||
const numContractsToSend = 6
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||
})
|
||||
const trendingContracts = (await getTrendingContracts())
|
||||
.filter(
|
||||
(contract) =>
|
||||
!(
|
||||
contract.question.toLowerCase().includes('trump') &&
|
||||
contract.question.toLowerCase().includes('president')
|
||||
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS
|
||||
)
|
||||
.slice(0, 20)
|
||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
continue
|
||||
}
|
||||
const contractsAvailableToSend = trendingContracts.filter((contract) => {
|
||||
return !contract.uniqueBettorIds?.includes(privateUser.id)
|
||||
})
|
||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
||||
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
||||
continue
|
||||
}
|
||||
// choose random subset of contracts to send to user
|
||||
const contractsToSend = chooseRandomSubset(
|
||||
contractsAvailableToSend,
|
||||
numContractsToSend
|
||||
)
|
||||
|
||||
const user = await getUser(privateUser.id)
|
||||
if (!user) continue
|
||||
|
||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||
}
|
||||
}
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
shuffle(contracts, createRNG(seed))
|
||||
return contracts.slice(0, count)
|
||||
}
|
|
@ -1,61 +1,7 @@
|
|||
import { ReactNode } from 'react'
|
||||
import Head from 'next/head'
|
||||
import { Challenge } from 'common/challenge'
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
import { buildCardUrl, OgCardProps } from 'common/contract-details'
|
||||
|
||||
export function SEO(props: {
|
||||
title: string
|
||||
|
|
|
@ -3,7 +3,6 @@ import React from 'react'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { SiteLink } from './site-link'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
|
@ -37,7 +36,7 @@ export function AmountInput(props: {
|
|||
|
||||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group">
|
||||
<label className="input-group mb-4">
|
||||
<span className="bg-gray-200 text-sm">{label}</span>
|
||||
<input
|
||||
className={clsx(
|
||||
|
@ -57,8 +56,6 @@ export function AmountInput(props: {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
{error && (
|
||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||
{error === 'Insufficient balance' ? (
|
||||
|
@ -115,6 +112,8 @@ export function BuyAmountInput(props: {
|
|||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Router from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { MouseEvent, useState } from 'react'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
|
||||
export function Avatar(props: {
|
||||
|
@ -12,6 +12,7 @@ export function Avatar(props: {
|
|||
}) {
|
||||
const { username, noLink, size, className } = props
|
||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
|
||||
const onClick =
|
||||
|
|
|
@ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
|
|||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
||||
export default function BetRow(props: {
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
btnClassName?: string
|
127
web/components/bet-inline.tsx
Normal file
127
web/components/bet-inline.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
import { track } from '@amplitude/analytics-browser'
|
||||
import clsx from 'clsx'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||
import { APIError } from 'web/lib/firebase/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation } from 'react-query'
|
||||
import { placeBet } from 'web/lib/firebase/api'
|
||||
import { BuyAmountInput } from './amount-input'
|
||||
import { Button } from './button'
|
||||
import { Row } from './layout/row'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { Col } from './layout/col'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
// adapted from bet-panel.ts
|
||||
export function BetInline(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
setProbAfter: (probAfter: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { contract, className, setProbAfter, onClose } = props
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
|
||||
const [amount, setAmount] = useState<number>()
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
const { newPool, newP } = getBinaryCpmmBetInfo(
|
||||
outcome ?? 'YES',
|
||||
amount ?? 0,
|
||||
contract,
|
||||
undefined,
|
||||
unfilledBets
|
||||
)
|
||||
const resultProb = getCpmmProbability(newPool, newP)
|
||||
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
||||
|
||||
const submitBet = useMutation(
|
||||
() => placeBet({ outcome, amount, contractId: contract.id }),
|
||||
{
|
||||
onError: (e) =>
|
||||
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
|
||||
onSuccess: () => {
|
||||
track('bet', {
|
||||
location: 'embed',
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
outcome,
|
||||
isLimitOrder: false,
|
||||
})
|
||||
setAmount(undefined)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// reset error / success state on user change
|
||||
useEffect(() => {
|
||||
amount && submitBet.reset()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [outcome, amount])
|
||||
|
||||
const tooFewFunds = error === 'Insufficient balance'
|
||||
|
||||
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
|
||||
|
||||
return (
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
||||
<div className="text-xl">Bet</div>
|
||||
<YesNoSelector
|
||||
className="space-x-0"
|
||||
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
selected={outcome}
|
||||
onSelect={setOutcome}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
/>
|
||||
<BuyAmountInput
|
||||
className="-mb-4"
|
||||
inputClassName={clsx(
|
||||
'input-sm w-20 !text-base',
|
||||
error && 'input-error'
|
||||
)}
|
||||
amount={amount}
|
||||
onChange={setAmount}
|
||||
error="" // handle error ourselves
|
||||
setError={setError}
|
||||
/>
|
||||
{user && (
|
||||
<Button
|
||||
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
|
||||
size="xs"
|
||||
disabled={betDisabled}
|
||||
onClick={() => submitBet.mutate()}
|
||||
>
|
||||
{submitBet.isLoading
|
||||
? 'Submitting'
|
||||
: submitBet.isSuccess
|
||||
? 'Success!'
|
||||
: 'Submit'}
|
||||
</Button>
|
||||
)}
|
||||
<SignUpPrompt size="xs" />
|
||||
<button onClick={onClose}>
|
||||
<XIcon className="ml-1 h-6 w-6" />
|
||||
</button>
|
||||
</Row>
|
||||
{error && (
|
||||
<div className="text-error my-1 text-sm">
|
||||
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -534,9 +534,8 @@ export function ContractBetsTable(props: {
|
|||
contract: Contract
|
||||
bets: Bet[]
|
||||
isYourBets: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className, isYourBets } = props
|
||||
const { contract, isYourBets } = props
|
||||
|
||||
const bets = sortBy(
|
||||
props.bets.filter((b) => !b.isAnte && b.amount !== 0),
|
||||
|
@ -568,7 +567,7 @@ export function ContractBetsTable(props: {
|
|||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
{amountRedeemed > 0 && (
|
||||
<>
|
||||
<div className="pl-2 text-sm text-gray-500">
|
||||
|
@ -771,7 +770,7 @@ function SellButton(props: {
|
|||
setIsSubmitting(false)
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 text-2xl">
|
||||
<div className="mb-4 text-xl">
|
||||
Sell {formatWithCommas(shares)} shares of{' '}
|
||||
<OutcomeLabel outcome={outcome} contract={contract} truncate="long" />{' '}
|
||||
for {formatMoney(saleAmount)}?
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
children?: ReactNode
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
color?:
|
||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
export type ColorType =
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
|
@ -15,6 +11,13 @@ export function Button(props: {
|
|||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
|
||||
export function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
children?: ReactNode
|
||||
size?: SizeType
|
||||
color?: ColorType
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Button } from '../button'
|
||||
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 { formatMoney } from 'common/util/format'
|
||||
import { NoLabel, YesLabel } from '../outcome-label'
|
||||
|
@ -19,24 +19,32 @@ import { QRCode } from '../qr-code'
|
|||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { FIXED_ANTE } from 'common/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'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
outcome: 'YES' | 'NO' | number
|
||||
acceptorAmount: number
|
||||
question: string
|
||||
}
|
||||
|
||||
export function CreateChallengeModal(props: {
|
||||
user: User | null | undefined
|
||||
contract: BinaryContract
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
contract?: BinaryContract
|
||||
}) {
|
||||
const { user, contract, isOpen, setOpen } = props
|
||||
const [challengeSlug, setChallengeSlug] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { editor } = useTextEditor({ placeholder: '' })
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
|
@ -46,24 +54,42 @@ export function CreateChallengeModal(props: {
|
|||
<CreateChallengeForm
|
||||
user={user}
|
||||
contract={contract}
|
||||
loading={loading}
|
||||
onCreate={async (newChallenge) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const challengeContract = contract
|
||||
? contract
|
||||
: await createMarket(
|
||||
removeUndefinedProps({
|
||||
question: newChallenge.question,
|
||||
outcomeType: 'BINARY',
|
||||
initialProb: 50,
|
||||
description: editor?.getJSON(),
|
||||
ante: FIXED_ANTE,
|
||||
closeTime: dayjs().add(30, 'day').valueOf(),
|
||||
})
|
||||
)
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
message: newChallenge.message,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: contract,
|
||||
contract: challengeContract as BinaryContract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: contract.id,
|
||||
contractId: challengeContract.id,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("couldn't create market/challenge:", e)
|
||||
}
|
||||
setLoading(false)
|
||||
}}
|
||||
challengeSlug={challengeSlug}
|
||||
/>
|
||||
|
@ -75,25 +101,24 @@ export function CreateChallengeModal(props: {
|
|||
|
||||
function CreateChallengeForm(props: {
|
||||
user: User
|
||||
contract: BinaryContract
|
||||
onCreate: (m: challengeInfo) => Promise<void>
|
||||
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 [finishedCreating, setFinishedCreating] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||
const defaultExpire = 'week'
|
||||
|
||||
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
|
||||
|
||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||
outcome: 'YES',
|
||||
amount: 100,
|
||||
acceptorAmount: 100,
|
||||
message: defaultMessage,
|
||||
question: contract ? contract.question : '',
|
||||
})
|
||||
useEffect(() => {
|
||||
setError('')
|
||||
|
@ -106,7 +131,15 @@ function CreateChallengeForm(props: {
|
|||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
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
|
||||
}
|
||||
setIsCreating(true)
|
||||
|
@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
|
|||
|
||||
<div className="mb-8">
|
||||
Challenge a friend to bet on{' '}
|
||||
{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 className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
|
@ -187,6 +236,7 @@ function CreateChallengeForm(props: {
|
|||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
{contract && (
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
|
@ -202,7 +252,7 @@ function CreateChallengeForm(props: {
|
|||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
|
||||
)}
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
|
@ -210,7 +260,18 @@ function CreateChallengeForm(props: {
|
|||
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
||||
)}
|
||||
</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>
|
||||
|
||||
<Row className="mt-8 items-center">
|
||||
|
@ -218,10 +279,8 @@ function CreateChallengeForm(props: {
|
|||
type="submit"
|
||||
color={'gradient'}
|
||||
size="xl"
|
||||
className={clsx(
|
||||
'whitespace-nowrap drop-shadow-md',
|
||||
isCreating ? 'disabled' : ''
|
||||
)}
|
||||
disabled={isCreating || challengeInfo.question === ''}
|
||||
className={clsx('whitespace-nowrap drop-shadow-md')}
|
||||
>
|
||||
Create challenge bet
|
||||
</Button>
|
||||
|
@ -229,7 +288,12 @@ function CreateChallengeForm(props: {
|
|||
<Row className={'text-error'}>{error} </Row>
|
||||
</form>
|
||||
)}
|
||||
{finishedCreating && (
|
||||
{loading && (
|
||||
<Col className={'h-56 w-full items-center justify-center'}>
|
||||
<LoadingIndicator />
|
||||
</Col>
|
||||
)}
|
||||
{finishedCreating && !loading && (
|
||||
<>
|
||||
<Title className="!my-0" text="Challenge Created!" />
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Comment } from 'common/comment'
|
||||
import { Comment, ContractComment } from 'common/comment'
|
||||
import { groupConsecutive } from 'common/util/array'
|
||||
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||
import { SiteLink } from './site-link'
|
||||
|
@ -16,12 +16,6 @@ import { LoadingIndicator } from './loading-indicator'
|
|||
|
||||
const COMMENTS_PER_PAGE = 50
|
||||
|
||||
type ContractComment = Comment & {
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
}
|
||||
|
||||
function contractPath(slug: string) {
|
||||
// by convention this includes the contract creator username, but we don't
|
||||
// have that handy, so we just put /market/
|
||||
|
@ -38,7 +32,9 @@ export function UserCommentsList(props: { user: User }) {
|
|||
useEffect(() => {
|
||||
getUsersComments(user.id).then((cs) => {
|
||||
// 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])
|
||||
|
||||
|
|
|
@ -83,7 +83,6 @@ export function ContractSearch(props: {
|
|||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
hideOrderSelector?: boolean
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
|
@ -91,6 +90,7 @@ export function ContractSearch(props: {
|
|||
headerClassName?: string
|
||||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
isWholePage?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -98,13 +98,13 @@ export function ContractSearch(props: {
|
|||
defaultFilter,
|
||||
additionalFilter,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
headerClassName,
|
||||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
isWholePage,
|
||||
} = props
|
||||
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
|
@ -139,7 +139,7 @@ export function ContractSearch(props: {
|
|||
setNumPages(results.nbPages)
|
||||
if (freshQuery) {
|
||||
setPages([newPage])
|
||||
window.scrollTo(0, 0)
|
||||
if (isWholePage) window.scrollTo(0, 0)
|
||||
} else {
|
||||
setPages((pages) => [...pages, newPage])
|
||||
}
|
||||
|
@ -181,7 +181,6 @@ export function ContractSearch(props: {
|
|||
loadMore={performQuery}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
|
@ -256,9 +255,12 @@ function ContractSearchControls(props: {
|
|||
? additionalFilters
|
||||
: [
|
||||
...additionalFilters,
|
||||
additionalFilter ? '' : 'visibility:public',
|
||||
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
|
||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${pillFilter}`
|
||||
: '',
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
import { Contract } from 'common/contract'
|
||||
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { getProbability } from 'common/calculate'
|
||||
|
||||
export const getOpenGraphProps = (contract: Contract) => {
|
||||
const {
|
||||
resolution,
|
||||
question,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
outcomeType,
|
||||
creatorAvatarUrl,
|
||||
description: desc,
|
||||
} = contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||
|
||||
const numericValue =
|
||||
outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getFormattedMappedValue(contract)(getProbability(contract))
|
||||
: undefined
|
||||
|
||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${stringDesc}`
|
||||
: probPercent
|
||||
? `${probPercent} chance. ${stringDesc}`
|
||||
: stringDesc
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
description,
|
||||
numericValue,
|
||||
}
|
||||
}
|
|
@ -120,7 +120,7 @@ export function ContractCard(props: {
|
|||
truncate={'long'}
|
||||
/>
|
||||
) : (
|
||||
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
||||
<FreeResponseTopAnswer contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
|
@ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: {
|
|||
contract: BinaryContract
|
||||
large?: boolean
|
||||
className?: string
|
||||
probAfter?: number // 0 to 1
|
||||
}) {
|
||||
const { contract, large, className } = props
|
||||
const { contract, large, className, probAfter } = props
|
||||
const { resolution } = contract
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
const before = getBinaryProbPercent(contract)
|
||||
const after = probAfter && formatPercent(probAfter)
|
||||
const probChanged = before !== after
|
||||
|
||||
return (
|
||||
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
||||
{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')}>
|
||||
chance
|
||||
</div>
|
||||
|
@ -218,10 +230,9 @@ export function BinaryResolutionOrChance(props: {
|
|||
|
||||
function FreeResponseTopAnswer(props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, truncate } = props
|
||||
const { contract } = props
|
||||
|
||||
const topAnswer = getTopAnswer(contract)
|
||||
|
||||
|
@ -229,7 +240,7 @@ function FreeResponseTopAnswer(props: {
|
|||
<AnswerLabel
|
||||
className="!text-gray-600"
|
||||
answer={topAnswer}
|
||||
truncate={truncate}
|
||||
truncate="long"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
@ -9,11 +9,7 @@ import {
|
|||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { UserLink } from '../user-page'
|
||||
import {
|
||||
Contract,
|
||||
contractMetrics,
|
||||
updateContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
import dayjs from 'dayjs'
|
||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||
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 { insertContent } from '../editor/utils'
|
||||
import clsx from 'clsx'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
|
||||
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: {
|
||||
closeTime: number
|
||||
contract: Contract
|
||||
|
|
|
@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
<tr>
|
||||
<td>Payout</td>
|
||||
<td>
|
||||
<td className="flex gap-1">
|
||||
{mechanism === 'cpmm-1' ? (
|
||||
<>
|
||||
Fixed{' '}
|
||||
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<>
|
||||
Parimutuel{' '}
|
||||
<InfoTooltip text="Each share is a fraction of the pool. " />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
@ -65,7 +65,7 @@ export function ContractLeaderboard(props: {
|
|||
export function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
import { Bet } from 'common/bet'
|
||||
import BetRow from '../bet-row'
|
||||
import BetButton from '../bet-button'
|
||||
import { AnswersGraph } from '../answers/answers-graph'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractDescription } from './contract-description'
|
||||
|
@ -73,18 +73,18 @@ export const ContractOverview = (props: {
|
|||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow contract={contract as CPMMBinaryContract} />
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { ContractActivity } from '../feed/contract-activity'
|
||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||
|
@ -15,7 +15,7 @@ export function ContractTabs(props: {
|
|||
contract: Contract
|
||||
user: User | null | undefined
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, tips } = props
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useCallback } from 'react'
|
|||
import clsx from 'clsx'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { VisibilityObserver } from '../visibility-observer'
|
||||
import Masonry from 'react-masonry-css'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
|
@ -20,7 +21,6 @@ export function ContractsGrid(props: {
|
|||
loadMore?: () => void
|
||||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
|
@ -32,7 +32,6 @@ export function ContractsGrid(props: {
|
|||
showTime,
|
||||
loadMore,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
|
@ -64,12 +63,11 @@ export function ContractsGrid(props: {
|
|||
|
||||
return (
|
||||
<Col className="gap-8">
|
||||
<ul
|
||||
className={clsx(
|
||||
overrideGridClassName
|
||||
? overrideGridClassName
|
||||
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
|
||||
)}
|
||||
<Masonry
|
||||
// Show only 1 column on tailwind's md breakpoint (768px)
|
||||
breakpointCols={{ default: 2, 768: 1 }}
|
||||
className="-ml-4 flex w-auto"
|
||||
columnClassName="pl-4 bg-clip-padding"
|
||||
>
|
||||
{contracts.map((contract) => (
|
||||
<ContractCard
|
||||
|
@ -81,14 +79,13 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
className={
|
||||
contractIds?.includes(contract.id)
|
||||
? highlightClassName
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Masonry>
|
||||
<VisibilityObserver
|
||||
onVisibilityUpdated={onVisibilityUpdated}
|
||||
className="relative -top-96 h-1"
|
||||
|
|
|
@ -23,7 +23,7 @@ import { useState } from 'react'
|
|||
import toast from 'react-hot-toast'
|
||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
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 TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
|
||||
import { Col } from '../layout/col'
|
||||
|
@ -34,6 +34,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
|||
import { track } from 'web/lib/service/analytics'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { getBinaryProb } from 'common/contract-details'
|
||||
|
||||
const BET_SIZE = 10
|
||||
|
||||
|
|
|
@ -65,9 +65,6 @@ export function MarketModal(props: {
|
|||
<ContractSearch
|
||||
hideOrderSelector
|
||||
onContractClick={addContract}
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
highlightOptions={{
|
||||
contractIds: contracts.map((c) => c.id),
|
||||
|
|
|
@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
|||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)}
|
||||
onClick={() => submitUser(i)}
|
||||
key={user.id}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||
{user.username}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash'
|
|||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
import { getOutcomeProbability } from 'common/calculate'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
|
@ -28,7 +28,7 @@ type BaseActivityItem = {
|
|||
export type CommentInputItem = BaseActivityItem & {
|
||||
type: 'commentInput'
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: Comment[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
}
|
||||
|
||||
export type DescriptionItem = BaseActivityItem & {
|
||||
|
@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & {
|
|||
|
||||
export type CommentThreadItem = BaseActivityItem & {
|
||||
type: 'commentThread'
|
||||
parentComment: Comment
|
||||
comments: Comment[]
|
||||
parentComment: ContractComment
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
|
|||
type: 'answergroup'
|
||||
user: User | undefined | null
|
||||
answer: Answer
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & {
|
|||
function getAnswerAndCommentInputGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
tips: CommentTipMap,
|
||||
user: User | undefined | null
|
||||
) {
|
||||
|
@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups(
|
|||
|
||||
function getCommentThreads(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
tips: CommentTipMap,
|
||||
contract: Contract
|
||||
) {
|
||||
|
@ -135,7 +135,7 @@ function getCommentThreads(
|
|||
return items
|
||||
}
|
||||
|
||||
function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
||||
function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
|
||||
return (
|
||||
comment.answerOutcome === undefined &&
|
||||
(contract.outcomeType === 'FREE_RESPONSE'
|
||||
|
@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
|||
export function getSpecificContractActivityItems(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
liquidityProvisions: LiquidityProvision[],
|
||||
tips: CommentTipMap,
|
||||
user: User | null | undefined,
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import { union, difference } from 'lodash'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Col } from '../layout/col'
|
||||
import { useState } from 'react'
|
||||
import { updateUser, User } from 'web/lib/firebase/users'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function CategorySelector(props: {
|
||||
category: string
|
||||
setCategory: (category: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { className, category, setCategory } = props
|
||||
|
||||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<CategoryButton
|
||||
key="all"
|
||||
category="All"
|
||||
isFollowed={category === 'all'}
|
||||
toggle={() => {
|
||||
setCategory('all')
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategoryButton
|
||||
key="following"
|
||||
category="Following"
|
||||
isFollowed={category === 'following'}
|
||||
toggle={() => {
|
||||
setCategory('following')
|
||||
}}
|
||||
/>
|
||||
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<CategoryButton
|
||||
key={cat}
|
||||
category={CATEGORIES[cat as category].split(' ')[0]}
|
||||
isFollowed={cat === category}
|
||||
toggle={() => {
|
||||
setCategory(cat)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryButton(props: {
|
||||
category: string
|
||||
isFollowed: boolean
|
||||
toggle: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const { toggle, category, isFollowed, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
||||
'cursor-pointer select-none',
|
||||
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
||||
)}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="text-sm text-gray-500">{category}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditCategoriesButton(props: {
|
||||
user: User
|
||||
className?: string
|
||||
}) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
track('edit categories button')
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Categories
|
||||
<CategorySelectorModal
|
||||
user={user}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategorySelectorModal(props: {
|
||||
user: User
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}) {
|
||||
const { user, isOpen, setIsOpen } = props
|
||||
const followedCategories =
|
||||
user?.followedCategories === undefined
|
||||
? CATEGORY_LIST
|
||||
: user.followedCategories
|
||||
|
||||
const selectAll =
|
||||
user.followedCategories === undefined ||
|
||||
followedCategories.length < CATEGORY_LIST.length
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
<Col className="rounded bg-white p-6">
|
||||
<button
|
||||
className="btn btn-sm btn-outline mb-4 self-start normal-case"
|
||||
onClick={() => {
|
||||
if (selectAll) {
|
||||
updateUser(user.id, {
|
||||
followedCategories: CATEGORY_LIST,
|
||||
})
|
||||
} else {
|
||||
updateUser(user.id, {
|
||||
followedCategories: [],
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select {selectAll ? 'all' : 'none'}
|
||||
</button>
|
||||
<Col className="grid w-full grid-cols-2 gap-4">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Checkbox
|
||||
className="col-span-1"
|
||||
key={cat}
|
||||
label={CATEGORIES[cat as category].split(' ')[0]}
|
||||
checked={followedCategories.includes(cat)}
|
||||
toggle={(checked) => {
|
||||
updateUser(user.id, {
|
||||
followedCategories: checked
|
||||
? difference(followedCategories, [cat])
|
||||
: union([cat], followedCategories),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Bet } from 'common/bet'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { getSpecificContractActivityItems } from './activity-items'
|
||||
|
@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision'
|
|||
export function ContractActivity(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
contract: any
|
||||
user: User | undefined | null
|
||||
answer: Answer
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}) {
|
||||
|
@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
])
|
||||
|
||||
const scrollAndOpenReplyInput = useEvent(
|
||||
(comment?: Comment, answer?: Answer) => {
|
||||
(comment?: ContractComment, answer?: Answer) => {
|
||||
setReplyToUser(
|
||||
comment
|
||||
? { id: comment.userId, username: comment.userUsername }
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react'
|
|||
|
||||
export function FeedCommentThread(props: {
|
||||
contract: Contract
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
parentComment: Comment
|
||||
parentComment: ContractComment
|
||||
bets: Bet[]
|
||||
smallAvatar?: boolean
|
||||
}) {
|
||||
|
@ -50,7 +50,7 @@ export function FeedCommentThread(props: {
|
|||
)
|
||||
commentsList.unshift(parentComment)
|
||||
|
||||
function scrollAndOpenReplyInput(comment: Comment) {
|
||||
function scrollAndOpenReplyInput(comment: ContractComment) {
|
||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||
setShowReply(true)
|
||||
}
|
||||
|
@ -95,10 +95,10 @@ export function FeedCommentThread(props: {
|
|||
|
||||
export function CommentRepliesList(props: {
|
||||
contract: Contract
|
||||
commentsList: Comment[]
|
||||
commentsList: ContractComment[]
|
||||
betsByUserId: Dictionary<Bet[]>
|
||||
tips: CommentTipMap
|
||||
scrollAndOpenReplyInput: (comment: Comment) => void
|
||||
scrollAndOpenReplyInput: (comment: ContractComment) => void
|
||||
bets: Bet[]
|
||||
treatFirstIndexEqually?: boolean
|
||||
smallAvatar?: boolean
|
||||
|
@ -156,12 +156,12 @@ export function CommentRepliesList(props: {
|
|||
|
||||
export function FeedComment(props: {
|
||||
contract: Contract
|
||||
comment: Comment
|
||||
comment: ContractComment
|
||||
tips: CommentTips
|
||||
betsBySameUser: Bet[]
|
||||
probAtCreatedTime?: number
|
||||
smallAvatar?: boolean
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
onReplyClick?: (comment: ContractComment) => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
|
@ -274,7 +274,7 @@ export function FeedComment(props: {
|
|||
|
||||
export function getMostRecentCommentableBet(
|
||||
betsByCurrentUser: Bet[],
|
||||
commentsByCurrentUser: Comment[],
|
||||
commentsByCurrentUser: ContractComment[],
|
||||
user?: User | null,
|
||||
answerOutcome?: string
|
||||
) {
|
||||
|
@ -319,7 +319,7 @@ function CommentStatus(props: {
|
|||
export function CommentInput(props: {
|
||||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: Comment[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
|
|
|
@ -11,7 +11,6 @@ import clsx from 'clsx'
|
|||
import { OutcomeLabel } from '../outcome-label'
|
||||
import {
|
||||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
tradingAllowed,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
@ -19,7 +18,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
|
|||
import { SiteLink } from '../site-link'
|
||||
import { Col } from '../layout/col'
|
||||
import { UserLink } from '../user-page'
|
||||
import BetRow from '../bet-row'
|
||||
import BetButton from '../bet-button'
|
||||
import { Avatar } from '../avatar'
|
||||
import { ActivityItem } from './activity-items'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -38,6 +37,7 @@ import { FeedLiquidity } from './feed-liquidity'
|
|||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
import { User } from 'common/user'
|
||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
|
@ -76,7 +76,7 @@ export function FeedItems(props: {
|
|||
) : (
|
||||
outcomeType === 'BINARY' &&
|
||||
tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
<BetButton
|
||||
contract={contract as CPMMBinaryContract}
|
||||
className={clsx('mb-2', betRowClassName)}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Bet } from 'common/bet'
|
||||
|
||||
const MAX_ACTIVE_CONTRACTS = 75
|
||||
|
@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) {
|
|||
// - Bet on market
|
||||
export function findActiveContracts(
|
||||
allContracts: Contract[],
|
||||
recentComments: Comment[],
|
||||
recentComments: ContractComment[],
|
||||
recentBets: Bet[],
|
||||
seenContracts: { [contractId: string]: number }
|
||||
) {
|
||||
|
@ -73,7 +73,7 @@ export function findActiveContracts(
|
|||
)
|
||||
const contractMostRecentComment = mapValues(
|
||||
contractComments,
|
||||
(comments) => maxBy(comments, (c) => c.createdTime) as Comment
|
||||
(comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
|
||||
)
|
||||
|
||||
const prioritizedContracts = sortBy(activeContracts, (c) => {
|
||||
|
|
|
@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user'
|
|||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
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 { track } from 'web/lib/service/analytics'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
|
|||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
|
||||
export function GroupChat(props: {
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
user: User | null | undefined
|
||||
group: Group
|
||||
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
|
||||
const groupedMessages = useMemo(() => {
|
||||
// Group messages with createdTime within 2 minutes of each other.
|
||||
const tempGrouped: Comment[][] = []
|
||||
const tempGrouped: GroupComment[][] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (i === 0) tempGrouped.push([message])
|
||||
|
@ -193,7 +194,7 @@ export function GroupChat(props: {
|
|||
}
|
||||
|
||||
export function GroupChatInBubble(props: {
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
user: User | null | undefined
|
||||
privateUser: PrivateUser | null | undefined
|
||||
group: Group
|
||||
|
@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: {
|
|||
|
||||
const GroupMessage = memo(function GroupMessage_(props: {
|
||||
user: User | null | undefined
|
||||
comments: Comment[]
|
||||
comments: GroupComment[]
|
||||
group: Group
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
setRef?: (ref: HTMLDivElement) => void
|
||||
|
|
|
@ -22,20 +22,20 @@ export function LimitBets(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { contract, bets, className } = props
|
||||
const sortedBets = sortBy(
|
||||
bets,
|
||||
(bet) => -1 * bet.limitProb,
|
||||
(bet) => -1 * bet.createdTime
|
||||
)
|
||||
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 (
|
||||
<Col className={className}>
|
||||
{yourBets.length === 0 && (
|
||||
<OrderBookButton
|
||||
className="self-end"
|
||||
limitBets={sortedBets}
|
||||
limitBets={bets}
|
||||
contract={contract}
|
||||
/>
|
||||
)}
|
||||
|
@ -49,7 +49,7 @@ export function LimitBets(props: {
|
|||
|
||||
<OrderBookButton
|
||||
className="self-end"
|
||||
limitBets={sortedBets}
|
||||
limitBets={bets}
|
||||
contract={contract}
|
||||
/>
|
||||
</Row>
|
||||
|
@ -163,8 +163,16 @@ export function OrderBookButton(props: {
|
|||
const { limitBets, contract, className } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const yesBets = limitBets.filter((bet) => bet.outcome === 'YES')
|
||||
const noBets = limitBets.filter((bet) => bet.outcome === 'NO').reverse()
|
||||
const yesBets = sortBy(
|
||||
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 (
|
||||
<>
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
|
|||
import { NoLabel, YesLabel } from './outcome-label'
|
||||
import { Col } from './layout/col'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
|
@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="align-center mb-4 text-gray-500">
|
||||
Subsidize this market by adding M$ to the liquidity pool.
|
||||
<div className="mb-4 text-gray-500">
|
||||
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>
|
||||
|
||||
<Row>
|
||||
|
|
|
@ -33,7 +33,7 @@ function getNavigation() {
|
|||
|
||||
const signedOutNavigation = [
|
||||
{ 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
|
||||
|
|
|
@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
const signedOutNavigation = [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
|
|
|
@ -90,13 +90,11 @@ export function FreeResponseOutcomeLabel(props: {
|
|||
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||
return (
|
||||
<Tooltip text={chosen.text}>
|
||||
<AnswerLabel
|
||||
answer={chosen}
|
||||
truncate={truncate}
|
||||
className={answerClassName}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -165,11 +163,13 @@ export function AnswerLabel(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Tooltip text={truncated === text ? false : text}>
|
||||
<span
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
className={clsx('whitespace-pre-line break-words', className)}
|
||||
>
|
||||
{truncated}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
38
web/components/profile/betting-streak-modal.tsx
Normal file
38
web/components/profile/betting-streak-modal.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
BETTING_STREAK_BONUS_MAX,
|
||||
} from 'common/numeric-constants'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function BettingStreakModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { isOpen, setOpen } = props
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🔥</span>
|
||||
<span>Daily betting streaks</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are they?</span>
|
||||
<span className={'ml-2'}>
|
||||
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
|
||||
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
|
||||
. The more days you bet in a row, the more you earn!
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• Where can I check my streak?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
You can see your current streak on the top right of your profile
|
||||
page.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -2,17 +2,21 @@ import React from 'react'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Button } from './button'
|
||||
import { Button, SizeType } from './button'
|
||||
|
||||
export function SignUpPrompt(props: { label?: string; className?: string }) {
|
||||
const { label, className } = props
|
||||
export function SignUpPrompt(props: {
|
||||
label?: string
|
||||
className?: string
|
||||
size?: SizeType
|
||||
}) {
|
||||
const { label, className, size = 'lg' } = props
|
||||
const user = useUser()
|
||||
|
||||
return user === null ? (
|
||||
<Button
|
||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||
className={className}
|
||||
size="lg"
|
||||
size={size}
|
||||
color="gradient"
|
||||
>
|
||||
{label ?? 'Sign up to bet!'}
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { parseWordsAsTags } from 'common/util/parse'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { TagsList } from './tags-list'
|
||||
import { MAX_TAG_LENGTH } from 'common/contract'
|
||||
|
||||
export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||
const { contract, className } = props
|
||||
const { tags } = contract
|
||||
|
||||
const [tagText, setTagText] = useState('')
|
||||
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const updateTags = async () => {
|
||||
setIsSubmitting(true)
|
||||
await updateContract(contract.id, {
|
||||
tags: newTags,
|
||||
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
setTagText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={clsx('gap-4', className)}>
|
||||
<TagsList tags={newTags} noLabel />
|
||||
|
||||
<Row className="items-center gap-4">
|
||||
<input
|
||||
style={{ maxWidth: 150 }}
|
||||
placeholder="Type a tag..."
|
||||
className="input input-sm input-bordered resize-none"
|
||||
disabled={isSubmitting}
|
||||
value={tagText}
|
||||
maxLength={MAX_TAG_LENGTH}
|
||||
onChange={(e) => setTagText(e.target.value || '')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
updateTags()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-xs btn-outline" onClick={updateTags}>
|
||||
Save tags
|
||||
</button>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { CATEGORIES, category } from '../../common/categories'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
import { Row } from './layout/row'
|
||||
import { SiteLink } from './site-link'
|
||||
|
||||
function Hashtag(props: { tag: string; noLink?: boolean }) {
|
||||
const { tag, noLink } = props
|
||||
const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category]
|
||||
|
||||
const body = (
|
||||
<div className={clsx('', !noLink && 'cursor-pointer')}>
|
||||
<span className="text-sm">{category ? '#' + category : tag} </span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (noLink) return body
|
||||
return (
|
||||
<SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center">
|
||||
{body}
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function TagsList(props: {
|
||||
tags: string[]
|
||||
className?: string
|
||||
noLink?: boolean
|
||||
noLabel?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const { tags, className, noLink, noLabel, label } = props
|
||||
return (
|
||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
||||
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
|
||||
{tags.map((tag) => (
|
||||
<Hashtag
|
||||
key={tag}
|
||||
tag={tag.startsWith('#') ? tag : `#${tag}`}
|
||||
noLink={noLink}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function FoldTag(props: { fold: { slug: string; name: string } }) {
|
||||
const { fold } = props
|
||||
const { slug, name } = fold
|
||||
|
||||
return (
|
||||
<SiteLink href={`/fold/${slug}`} className="flex items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full border-2 bg-white px-4 py-1 shadow-md',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm text-gray-500">{name}</span>
|
||||
</div>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function FoldTagList(props: {
|
||||
folds: { slug: string; name: string }[]
|
||||
noLabel?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { folds, noLabel, className } = props
|
||||
return (
|
||||
<Col className="gap-2">
|
||||
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
|
||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
||||
{folds.length > 0 && (
|
||||
<>
|
||||
{folds.map((fold) => (
|
||||
<FoldTag key={fold.slug} fold={fold} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
return
|
||||
}
|
||||
|
||||
const contractId =
|
||||
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||
const groupId =
|
||||
comment.commentType === 'group' ? comment.groupId : undefined
|
||||
await transact({
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
|
@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
toType: 'USER',
|
||||
token: 'M$',
|
||||
category: 'TIP',
|
||||
data: {
|
||||
contractId: comment.contractId,
|
||||
commentId: comment.id,
|
||||
groupId: comment.groupId,
|
||||
},
|
||||
data: { commentId: comment.id, contractId, groupId },
|
||||
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
||||
})
|
||||
|
||||
track('send comment tip', {
|
||||
contractId: comment.contractId,
|
||||
commentId: comment.id,
|
||||
groupId: comment.groupId,
|
||||
contractId,
|
||||
groupId,
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
toId: comment.userId,
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
Placement,
|
||||
shift,
|
||||
useFloating,
|
||||
useFocus,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useRole,
|
||||
|
@ -48,7 +47,6 @@ export function Tooltip(props: {
|
|||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
useHover(context, { mouseOnly: noTap }),
|
||||
useFocus(context),
|
||||
useRole(context, { role: '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
|
||||
className={clsx('inline-block', className)}
|
||||
ref={reference}
|
||||
tabIndex={noTap ? undefined : 0}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{children}
|
||||
|
@ -82,7 +79,7 @@ export function Tooltip(props: {
|
|||
role="tooltip"
|
||||
ref={floating}
|
||||
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()}
|
||||
>
|
||||
{text}
|
||||
|
|
|
@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -58,8 +59,6 @@ export function UserLink(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
||||
|
||||
export function UserPage(props: { user: User }) {
|
||||
const { user } = props
|
||||
const router = useRouter()
|
||||
|
@ -67,11 +66,29 @@ export function UserPage(props: { user: User }) {
|
|||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
setShowConfetti(claimedMana)
|
||||
}, [router])
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
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
|
||||
|
||||
|
@ -85,6 +102,10 @@ export function UserPage(props: { user: User }) {
|
|||
{showConfetti && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
)}
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
/>
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
|
@ -103,10 +124,10 @@ export function UserPage(props: { user: User }) {
|
|||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<SiteLink className="btn" href="/profile">
|
||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
|
@ -116,9 +137,14 @@ export function UserPage(props: { user: User }) {
|
|||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'justify-between'}>
|
||||
<Col>
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
<span className="text-gray-500">
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
<Row className={'gap-3'}>
|
||||
<Col className={'items-center text-gray-500'}>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
|
@ -126,9 +152,19 @@ export function UserPage(props: { user: User }) {
|
|||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>{' '}
|
||||
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} />
|
||||
{user.bio && (
|
||||
<>
|
||||
|
@ -138,17 +174,7 @@ export function UserPage(props: { user: User }) {
|
|||
<Spacer h={4} />
|
||||
</>
|
||||
)}
|
||||
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center 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>
|
||||
|
||||
<Row className="flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
|
@ -198,7 +224,7 @@ export function UserPage(props: { user: User }) {
|
|||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={5} />
|
||||
{currentUser?.id === user.id && (
|
||||
<Row
|
||||
|
@ -208,7 +234,7 @@ export function UserPage(props: { user: User }) {
|
|||
>
|
||||
<span>
|
||||
<SiteLink href="/referrals">
|
||||
Refer a friend and earn {formatMoney(500)} when they sign up!
|
||||
Earn {formatMoney(500)} when you refer a friend!
|
||||
</SiteLink>{' '}
|
||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||
</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>
|
||||
|
|
|
@ -38,7 +38,7 @@ export function YesNoSelector(props: {
|
|||
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
||||
selected == 'YES'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-primary bg-transparent',
|
||||
: 'text-primary bg-white',
|
||||
btnClassName
|
||||
)}
|
||||
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',
|
||||
selected == 'NO'
|
||||
? 'bg-red-400 text-white'
|
||||
: 'bg-transparent text-red-400',
|
||||
: 'bg-white text-red-400',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => onSelect('NO')}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { feed } from 'common/feed'
|
||||
import { useTimeSinceFirstRender } from './use-time-since-first-render'
|
||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
||||
import { User } from 'common/user'
|
||||
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
|
||||
import {
|
||||
getRecentBetsAndComments,
|
||||
getTopWeeklyContracts,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useAlgoFeed = (
|
||||
user: User | null | undefined,
|
||||
category: string
|
||||
) => {
|
||||
const [allFeed, setAllFeed] = useState<feed>()
|
||||
const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>()
|
||||
|
||||
const getTime = useTimeSinceFirstRender()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getUserFeed(user.id).then((feed) => {
|
||||
if (feed.length === 0) {
|
||||
getDefaultFeed().then((feed) => setAllFeed(feed))
|
||||
} else setAllFeed(feed)
|
||||
|
||||
trackLatency(user.id, 'feed', getTime())
|
||||
console.log('"all" feed load time', getTime())
|
||||
})
|
||||
|
||||
getCategoryFeeds(user.id).then((feeds) => {
|
||||
setCategoryFeeds(feeds)
|
||||
console.log('category feeds load time', getTime())
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id])
|
||||
|
||||
const feed = category === 'all' ? allFeed : categoryFeeds?.[category]
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
const getDefaultFeed = async () => {
|
||||
const contracts = await getTopWeeklyContracts()
|
||||
const feed = await Promise.all(
|
||||
contracts.map((c) => getRecentBetsAndComments(c))
|
||||
)
|
||||
return feed
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import {
|
||||
Comment,
|
||||
listenForCommentsOnContract,
|
||||
listenForCommentsOnGroup,
|
||||
listenForRecentComments,
|
||||
} from 'web/lib/firebase/comments'
|
||||
|
||||
export const useComments = (contractId: string) => {
|
||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForCommentsOnContract(contractId, setComments)
|
||||
|
@ -16,7 +16,7 @@ export const useComments = (contractId: string) => {
|
|||
return comments
|
||||
}
|
||||
export const useCommentsOnGroup = (groupId: string | undefined) => {
|
||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||
const [comments, setComments] = useState<GroupComment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
|
||||
|
|
|
@ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType === 'bonus' || notification.sourceType === 'tip'
|
||||
notification.sourceType === 'bonus' ||
|
||||
notification.sourceType === 'tip' ||
|
||||
notification.sourceType === 'betting_streak_bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
|
||||
notification.sourceType !== 'bonus' &&
|
||||
notification.sourceType !== 'tip' &&
|
||||
notification.sourceType !== 'betting_streak_bonus'
|
||||
)
|
||||
if (incomeNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
|
|
|
@ -29,13 +29,11 @@ export async function createChallenge(data: {
|
|||
creatorAmount: number
|
||||
acceptorAmount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
}) {
|
||||
const {
|
||||
creator,
|
||||
creatorAmount,
|
||||
expiresTime,
|
||||
message,
|
||||
contract,
|
||||
outcome,
|
||||
acceptorAmount,
|
||||
|
@ -73,7 +71,7 @@ export async function createChallenge(data: {
|
|||
acceptedByUserIds: [],
|
||||
acceptances: [],
|
||||
isResolved: false,
|
||||
message,
|
||||
message: '',
|
||||
}
|
||||
|
||||
await setDoc(doc(challenges(contract.id), slug), challenge)
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { User } from 'common/user'
|
||||
import { Comment } from 'common/comment'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
|
@ -31,8 +31,10 @@ export async function createCommentOnContract(
|
|||
const ref = betId
|
||||
? doc(getCommentsCollection(contractId), betId)
|
||||
: doc(getCommentsCollection(contractId))
|
||||
const comment: Comment = removeUndefinedProps({
|
||||
// contract slug and question are set via trigger
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
commentType: 'contract',
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
content: content,
|
||||
|
@ -59,8 +61,9 @@ export async function createCommentOnGroup(
|
|||
replyToCommentId?: string
|
||||
) {
|
||||
const ref = doc(getCommentsOnGroupCollection(groupId))
|
||||
const comment: Comment = removeUndefinedProps({
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
commentType: 'group',
|
||||
groupId,
|
||||
userId: user.id,
|
||||
content: content,
|
||||
|
@ -94,7 +97,7 @@ export async function listAllComments(contractId: string) {
|
|||
}
|
||||
|
||||
export async function listAllCommentsOnGroup(groupId: string) {
|
||||
const comments = await getValues<Comment>(
|
||||
const comments = await getValues<GroupComment>(
|
||||
getCommentsOnGroupCollection(groupId)
|
||||
)
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -103,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) {
|
|||
|
||||
export function listenForCommentsOnContract(
|
||||
contractId: string,
|
||||
setComments: (comments: Comment[]) => void
|
||||
setComments: (comments: ContractComment[]) => void
|
||||
) {
|
||||
return listenForValues<Comment>(
|
||||
return listenForValues<ContractComment>(
|
||||
getCommentsCollection(contractId),
|
||||
(comments) => {
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -115,9 +118,9 @@ export function listenForCommentsOnContract(
|
|||
}
|
||||
export function listenForCommentsOnGroup(
|
||||
groupId: string,
|
||||
setComments: (comments: Comment[]) => void
|
||||
setComments: (comments: GroupComment[]) => void
|
||||
) {
|
||||
return listenForValues<Comment>(
|
||||
return listenForValues<GroupComment>(
|
||||
getCommentsOnGroupCollection(groupId),
|
||||
(comments) => {
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import dayjs from 'dayjs'
|
||||
import {
|
||||
collection,
|
||||
deleteDoc,
|
||||
|
@ -17,15 +16,13 @@ import { sortBy, sum } from 'lodash'
|
|||
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { BinaryContract, Contract } from 'common/contract'
|
||||
import { getDpmProbability } from 'common/calculate-dpm'
|
||||
import { createRNG, shuffle } from 'common/util/random'
|
||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { getBinaryProb } from 'common/contract-details'
|
||||
|
||||
export const contracts = coll<Contract>('contracts')
|
||||
|
||||
|
@ -50,20 +47,6 @@ export function contractUrl(contract: 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) {
|
||||
return contract.mechanism === 'cpmm-1'
|
||||
? formatMoney(contract.totalLiquidity)
|
||||
|
@ -72,17 +55,6 @@ export function contractPool(contract: Contract) {
|
|||
: '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) {
|
||||
return formatPercent(getBinaryProb(contract))
|
||||
}
|
||||
|
@ -285,16 +257,6 @@ export async function getContractsBySlugs(slugs: string[]) {
|
|||
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(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
|
|
|
@ -14,20 +14,10 @@ import {
|
|||
onSnapshot,
|
||||
} from 'firebase/firestore'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
||||
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
||||
import { zip } from 'lodash'
|
||||
import { app, db } from './init'
|
||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||
import {
|
||||
coll,
|
||||
getValue,
|
||||
getValues,
|
||||
listenForValue,
|
||||
listenForValues,
|
||||
} from './utils'
|
||||
import { feed } from 'common/feed'
|
||||
import { CATEGORY_LIST } from 'common/categories'
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { safeLocalStorage } from '../util/local'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
|
||||
|
@ -202,20 +192,6 @@ export async function firebaseLogout() {
|
|||
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[]) {
|
||||
if (userIds.length > 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
||||
await setDoc(followDoc, {
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { Page } from 'web/components/page'
|
||||
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 { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
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 { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
||||
import { User } from 'common/user'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { listUsers } from 'web/lib/firebase/users'
|
||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||
import { Title } from 'web/components/title'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { getOpenGraphProps } from 'common/contract-details'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -78,7 +79,7 @@ export default function ContractPage(props: {
|
|||
contract: Contract | null
|
||||
username: string
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
slug: string
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
|
@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
|||
function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
|
|
|
@ -28,11 +28,11 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
|||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
||||
import Custom404 from 'web/pages/404'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { Title } from 'web/components/title'
|
||||
import { getOpenGraphProps } from 'common/contract-details'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
useAcceptedChallenges,
|
||||
useUserChallenges,
|
||||
} 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 { SiteLink } from 'web/components/site-link'
|
||||
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 { Modal } from 'web/components/layout/modal'
|
||||
import { QRCode } from 'web/components/qr-code'
|
||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
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() {
|
||||
const user = useUser()
|
||||
const challenges = useAcceptedChallenges()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const userChallenges = useUserChallenges(user?.id)
|
||||
.concat(
|
||||
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
|
||||
|
@ -70,8 +72,25 @@ export default function ChallengesListPage() {
|
|||
<Col className="w-full px-8">
|
||||
<Row className="items-center justify-between">
|
||||
<Title text="Challenges" />
|
||||
{CHALLENGES_ENABLED && (
|
||||
<Button size="lg" color="gradient" onClick={() => setOpen(true)}>
|
||||
Create Challenge
|
||||
<CreateChallengeModal
|
||||
isOpen={open}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</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]} />
|
||||
</Col>
|
||||
|
|
|
@ -26,7 +26,9 @@ import { User } from 'common/user'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
|
||||
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) =>
|
||||
sumBy(txns, (txn) => txn.amount)
|
||||
)
|
||||
|
@ -37,7 +39,8 @@ export async function getStaticProps() {
|
|||
])
|
||||
const matches = quadraticMatches(txns, totalRaised)
|
||||
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 {
|
||||
props: {
|
||||
|
@ -47,6 +50,7 @@ export async function getStaticProps() {
|
|||
txns,
|
||||
numDonors,
|
||||
mostRecentDonor,
|
||||
mostRecentCharity,
|
||||
},
|
||||
revalidate: 60,
|
||||
}
|
||||
|
@ -71,7 +75,7 @@ function DonatedStats(props: { stats: Stat[] }) {
|
|||
{stat.name}
|
||||
</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 ? (
|
||||
<SiteLink href={stat.url}>{stat.stat}</SiteLink>
|
||||
) : (
|
||||
|
@ -91,11 +95,21 @@ export default function Charity(props: {
|
|||
txns: Txn[]
|
||||
numDonors: number
|
||||
mostRecentDonor: User
|
||||
mostRecentCharity: string
|
||||
}) {
|
||||
const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props
|
||||
const {
|
||||
totalRaised,
|
||||
charities,
|
||||
matches,
|
||||
mostRecentCharity,
|
||||
mostRecentDonor,
|
||||
} = props
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const debouncedQuery = debounce(setQuery, 50)
|
||||
const recentCharityName =
|
||||
charities.find((charity) => charity.id === mostRecentCharity)?.name ??
|
||||
'Nobody'
|
||||
|
||||
const filterCharities = useMemo(
|
||||
() =>
|
||||
|
@ -143,15 +157,16 @@ export default function Charity(props: {
|
|||
name: 'Raised by Manifold users',
|
||||
stat: manaToUSD(totalRaised),
|
||||
},
|
||||
{
|
||||
name: 'Number of donors',
|
||||
stat: `${numDonors}`,
|
||||
},
|
||||
{
|
||||
name: 'Most recent donor',
|
||||
stat: mostRecentDonor.name ?? 'Nobody',
|
||||
url: `/${mostRecentDonor.username}`,
|
||||
},
|
||||
{
|
||||
name: 'Most recent donation',
|
||||
stat: recentCharityName,
|
||||
url: `/charity/${mostRecentCharity}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Spacer h={10} />
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
MAX_DESCRIPTION_LENGTH,
|
||||
MAX_QUESTION_LENGTH,
|
||||
outcomeType,
|
||||
visibility,
|
||||
} from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
@ -150,6 +151,7 @@ export function NewContract(props: {
|
|||
undefined
|
||||
)
|
||||
const [showGroupSelector, setShowGroupSelector] = useState(true)
|
||||
const [visibility, setVisibility] = useState<visibility>('public')
|
||||
|
||||
const closeTime = closeDate
|
||||
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
||||
|
@ -234,6 +236,7 @@ export function NewContract(props: {
|
|||
isLogScale,
|
||||
answers,
|
||||
groupId: selectedGroup?.id,
|
||||
visibility,
|
||||
})
|
||||
)
|
||||
track('create market', {
|
||||
|
@ -367,14 +370,30 @@ export function NewContract(props: {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className={'mt-2'}>
|
||||
<div className="form-control mb-1 items-start gap-1">
|
||||
<label className="label gap-2">
|
||||
<span className="mb-1">Visibility</span>
|
||||
<InfoTooltip text="Whether the market will be listed on the home page." />
|
||||
</label>
|
||||
<ChoicesToggleGroup
|
||||
currentChoice={visibility}
|
||||
setChoice={(choice) => setVisibility(choice as visibility)}
|
||||
choicesMap={{
|
||||
Public: 'public',
|
||||
Unlisted: 'unlisted',
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
<GroupSelector
|
||||
selectedGroup={selectedGroup}
|
||||
setSelectedGroup={setSelectedGroup}
|
||||
creator={creator}
|
||||
options={{ showSelector: showGroupSelector, showLabel: true }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { Contract } from 'common/contract'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
BinaryResolutionOrChance,
|
||||
FreeResponseResolutionOrChance,
|
||||
|
@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link'
|
|||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { listAllBets } from 'web/lib/firebase/bets'
|
||||
import {
|
||||
contractPath,
|
||||
|
@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
const href = `https://${DOMAIN}${contractPath(contract)}`
|
||||
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
const { setElem, height: topSectionHeight } = useMeasureSize()
|
||||
const paddingBottom = 8
|
||||
const { setElem, height: graphHeight } = useMeasureSize()
|
||||
|
||||
const graphHeight =
|
||||
windowHeight && topSectionHeight
|
||||
? windowHeight - topSectionHeight - paddingBottom
|
||||
: 0
|
||||
const [betPanelOpen, setBetPanelOpen] = useState(false)
|
||||
|
||||
const [probAfter, setProbAfter] = useState<number>()
|
||||
|
||||
return (
|
||||
<Col className="w-full flex-1 bg-white">
|
||||
<div className="relative flex flex-col pt-2" ref={setElem}>
|
||||
<Col className="h-[100vh] w-full bg-white">
|
||||
<div className="relative flex flex-col pt-2">
|
||||
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
||||
<SiteLink href={href}>{question}</SiteLink>
|
||||
</div>
|
||||
|
@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
disabled
|
||||
/>
|
||||
|
||||
{isBinary && (
|
||||
<Row className="items-center gap-4">
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract as CPMMBinaryContract}
|
||||
betPanelClassName="scale-75"
|
||||
/>
|
||||
{(isBinary || isPseudoNumeric) &&
|
||||
tradingAllowed(contract) &&
|
||||
!betPanelOpen && (
|
||||
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
||||
Bet
|
||||
</Button>
|
||||
)}
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
</Row>
|
||||
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
contract={contract}
|
||||
probAfter={probAfter}
|
||||
className="items-center"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<Row className="items-center gap-4">
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow contract={contract} betPanelClassName="scale-75" />
|
||||
)}
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
|
@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
<Spacer h={2} />
|
||||
</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) && (
|
||||
<ContractProbGraph
|
||||
contract={contract}
|
||||
|
|
|
@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
|||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { Button } from 'web/components/button'
|
||||
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'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
|
@ -123,7 +123,7 @@ export default function GroupPage(props: {
|
|||
topTraders: User[]
|
||||
creatorScores: { [userId: string]: number }
|
||||
topCreators: User[]
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
group: null,
|
||||
|
@ -551,7 +551,12 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
return (
|
||||
<>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -607,9 +612,6 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
user={user}
|
||||
hideOrderSelector={true}
|
||||
onContractClick={addContractToCurrentGroup}
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
additionalFilter={{ excludeContractIds: group.contractIds }}
|
||||
highlightOptions={{
|
||||
|
|
|
@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
|||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
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 { GetServerSideProps } from 'next'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
|
||||
})
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
|
||||
return { props: { auth } }
|
||||
}
|
||||
|
||||
const Home = (props: { auth: { user: User } }) => {
|
||||
const { user } = props.auth
|
||||
const Home = (props: { auth: { user: User } | null }) => {
|
||||
const user = props.auth ? props.auth.user : null
|
||||
const [contract, setContract] = useContractPage()
|
||||
|
||||
const router = useRouter()
|
||||
|
@ -42,6 +45,7 @@ const Home = (props: { auth: { user: User } }) => {
|
|||
// Update the url without switching pages in Nextjs.
|
||||
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
|
||||
}}
|
||||
isWholePage
|
||||
/>
|
||||
</Col>
|
||||
<button
|
||||
|
|
|
@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) {
|
|||
<Col className="items-center">
|
||||
<Col className="max-w-3xl">
|
||||
<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>
|
||||
</Page>
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ContractSearch } from '../components/contract-search'
|
||||
import { Page } from '../components/page'
|
||||
import { SEO } from '../components/SEO'
|
||||
|
||||
// TODO: Rename endpoint to "Explore"
|
||||
export default function Markets() {
|
||||
const user = useUser()
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Explore"
|
||||
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
|
||||
url="/markets"
|
||||
/>
|
||||
<ContractSearch user={user} />
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -31,7 +31,10 @@ import {
|
|||
import { TrendingUpIcon } from '@heroicons/react/outline'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
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 { track } from '@amplitude/analytics-browser'
|
||||
import { Pagination } from 'web/components/pagination'
|
||||
|
@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: {
|
|||
(n) => n.sourceType
|
||||
)
|
||||
for (const sourceType in groupedNotificationsBySourceType) {
|
||||
// Source title splits by contracts and groups
|
||||
// Source title splits by contracts, groups, betting streak bonus
|
||||
const groupedNotificationsBySourceTitle = groupBy(
|
||||
groupedNotificationsBySourceType[sourceType],
|
||||
(notification) => {
|
||||
return notification.sourceTitle ?? notification.sourceContractTitle
|
||||
}
|
||||
)
|
||||
for (const contractId in groupedNotificationsBySourceTitle) {
|
||||
const notificationsForContractId =
|
||||
groupedNotificationsBySourceTitle[contractId]
|
||||
if (notificationsForContractId.length === 1) {
|
||||
newNotifications.push(notificationsForContractId[0])
|
||||
for (const sourceTitle in groupedNotificationsBySourceTitle) {
|
||||
const notificationsForSourceTitle =
|
||||
groupedNotificationsBySourceTitle[sourceTitle]
|
||||
if (notificationsForSourceTitle.length === 1) {
|
||||
newNotifications.push(notificationsForSourceTitle[0])
|
||||
continue
|
||||
}
|
||||
let sum = 0
|
||||
notificationsForContractId.forEach(
|
||||
notificationsForSourceTitle.forEach(
|
||||
(notification) =>
|
||||
notification.sourceText &&
|
||||
(sum = parseInt(notification.sourceText) + sum)
|
||||
)
|
||||
const uniqueUsers = uniq(
|
||||
notificationsForContractId.map((notification) => {
|
||||
notificationsForSourceTitle.map((notification) => {
|
||||
return notification.sourceUserUsername
|
||||
})
|
||||
)
|
||||
|
||||
const newNotification = {
|
||||
...notificationsForContractId[0],
|
||||
...notificationsForSourceTitle[0],
|
||||
sourceText: sum.toString(),
|
||||
sourceUserUsername:
|
||||
uniqueUsers.length > 1
|
||||
? MULTIPLE_USERS_KEY
|
||||
: notificationsForContractId[0].sourceType,
|
||||
: notificationsForSourceTitle[0].sourceType,
|
||||
}
|
||||
newNotifications.push(newNotification)
|
||||
}
|
||||
|
@ -362,7 +365,8 @@ function IncomeNotificationItem(props: {
|
|||
justSummary?: boolean
|
||||
}) {
|
||||
const { notification, justSummary } = props
|
||||
const { sourceType, sourceUserName, sourceUserUsername } = notification
|
||||
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
|
||||
notification
|
||||
const [highlighted] = useState(!notification.isSeen)
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width && width < 768) || false
|
||||
|
@ -370,19 +374,82 @@ function IncomeNotificationItem(props: {
|
|||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
||||
function getReasonForShowingIncomeNotification(simple: boolean) {
|
||||
function reasonAndLink(simple: boolean) {
|
||||
const { sourceText } = notification
|
||||
let reasonText = ''
|
||||
|
||||
if (sourceType === 'bonus' && sourceText) {
|
||||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} unique traders`
|
||||
} unique traders on`
|
||||
: 'bonus on'
|
||||
} 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) {
|
||||
|
@ -392,19 +459,9 @@ function IncomeNotificationItem(props: {
|
|||
<div className={'flex pl-1 sm:pl-0'}>
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
<div className={'mr-1 text-black'}>
|
||||
<NotificationTextLabel
|
||||
className={'line-clamp-1'}
|
||||
notification={notification}
|
||||
justSummary={true}
|
||||
/>
|
||||
{incomeNotificationLabel()}
|
||||
</div>
|
||||
<span className={'flex truncate'}>
|
||||
{getReasonForShowingIncomeNotification(true)}
|
||||
<QuestionOrGroupLink
|
||||
notification={notification}
|
||||
ignoreClick={isMobile}
|
||||
/>
|
||||
</span>
|
||||
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -421,18 +478,16 @@ function IncomeNotificationItem(props: {
|
|||
>
|
||||
<div className={'relative'}>
|
||||
<SiteLink
|
||||
href={getSourceUrl(notification) ?? ''}
|
||||
href={getIncomeSourceUrl() ?? ''}
|
||||
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
|
||||
/>
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<div className={'line-clamp-2 flex max-w-xl shrink '}>
|
||||
<div className={'inline'}>
|
||||
<span className={'mr-1'}>
|
||||
<NotificationTextLabel notification={notification} />
|
||||
</span>
|
||||
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
|
||||
</div>
|
||||
<span>
|
||||
{sourceType != 'bonus' &&
|
||||
{sourceType === 'tip' &&
|
||||
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
||||
<span className={'mr-1 truncate'}>Multiple users</span>
|
||||
) : (
|
||||
|
@ -443,8 +498,7 @@ function IncomeNotificationItem(props: {
|
|||
short={true}
|
||||
/>
|
||||
))}
|
||||
{getReasonForShowingIncomeNotification(false)} {' on'}
|
||||
<QuestionOrGroupLink notification={notification} />
|
||||
{reasonAndLink(false)}
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
|
@ -794,9 +848,6 @@ function getSourceUrl(notification: Notification) {
|
|||
// User referral:
|
||||
if (sourceType === 'user' && !sourceContractSlug)
|
||||
return `/${sourceUserUsername}`
|
||||
if (sourceType === 'tip' && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
|
@ -885,12 +936,6 @@ function NotificationTextLabel(props: {
|
|||
return (
|
||||
<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) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ContractSearch } from '../../components/contract-search'
|
||||
import { Page } from '../../components/page'
|
||||
import { Title } from '../../components/title'
|
||||
|
||||
export default function TagPage() {
|
||||
const router = useRouter()
|
||||
const user = useUser()
|
||||
const { tag } = router.query as { tag: string }
|
||||
if (!router.isReady) return <div />
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Title text={`#${tag}`} />
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort="newest"
|
||||
defaultFilter="all"
|
||||
additionalFilter={{ tag }}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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/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>
|
||||
</urlset>
|
|
@ -9947,6 +9947,11 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
|||
dependencies:
|
||||
"@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:
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"
|
||||
|
|
Loading…
Reference in New Issue
Block a user