Merge branch 'main' into loans2
This commit is contained in:
commit
d79783ea25
|
@ -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,4 +18,21 @@ export type Comment = {
|
|||
userName: string
|
||||
userUsername: string
|
||||
userAvatarUrl?: 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>
|
||||
|
|
|
@ -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]])
|
||||
)
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { isEqual } from 'lodash'
|
||||
|
||||
export function filterDefined<T>(array: (T | null | undefined)[]) {
|
||||
return array.filter((item) => item !== null && item !== undefined) as T[]
|
||||
}
|
||||
|
@ -26,7 +28,7 @@ export function groupConsecutive<T, U>(xs: T[], key: (x: T) => U) {
|
|||
let curr = { key: key(xs[0]), items: [xs[0]] }
|
||||
for (const x of xs.slice(1)) {
|
||||
const k = key(x)
|
||||
if (k !== curr.key) {
|
||||
if (!isEqual(key, curr.key)) {
|
||||
result.push(curr)
|
||||
curr = { key: k, items: [x] }
|
||||
} else {
|
||||
|
|
|
@ -135,7 +135,8 @@ Requires no authorization.
|
|||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||
question: string
|
||||
description: string
|
||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||
textDescription: string // string description without formatting, images, or embeds
|
||||
|
||||
// A list of tags on each market. Any user can add tags to any market.
|
||||
// This list also includes the predefined categories shown as filters on the home page.
|
||||
|
@ -162,6 +163,8 @@ Requires no authorization.
|
|||
resolutionTime?: number
|
||||
resolution?: string
|
||||
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
||||
|
||||
lastUpdatedTime?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -541,6 +544,7 @@ Parameters:
|
|||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||
- `question`: Required. The headline question for the market.
|
||||
- `description`: Required. A long description describing the rules for the market.
|
||||
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||
- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch.
|
||||
- `tags`: Optional. An array of string tags for the market.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -496,6 +496,28 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "comments",
|
||||
"fieldPath": "contractId",
|
||||
"indexes": [
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "DESCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"arrayConfig": "CONTAINS",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "comments",
|
||||
"fieldPath": "createdTime",
|
||||
|
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node'
|
|||
import { DEV_CONFIG } from '../../common/envs/dev'
|
||||
import { PROD_CONFIG } from '../../common/envs/prod'
|
||||
|
||||
import { isProd } from './utils'
|
||||
import { isProd, tryOrLogError } from './utils'
|
||||
|
||||
const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
|
||||
|
||||
|
@ -15,10 +15,12 @@ export const track = async (
|
|||
eventProperties?: any,
|
||||
amplitudeProperties?: Partial<Amplitude.Event>
|
||||
) => {
|
||||
await amp.logEvent({
|
||||
event_type: eventName,
|
||||
user_id: userId,
|
||||
event_properties: eventProperties,
|
||||
...amplitudeProperties,
|
||||
})
|
||||
return await tryOrLogError(
|
||||
amp.logEvent({
|
||||
event_type: eventName,
|
||||
user_id: userId,
|
||||
event_properties: eventProperties,
|
||||
...amplitudeProperties,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,15 +14,17 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { chargeUser, getContract, isProd } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
FIXED_ANTE,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
|
@ -59,7 +61,7 @@ const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
|||
|
||||
const bodySchema = z.object({
|
||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||
description: descScehma.optional(),
|
||||
description: descScehma.or(z.string()).optional(),
|
||||
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
|
||||
closeTime: zTimestamp().refine(
|
||||
(date) => date.getTime() > new Date().getTime(),
|
||||
|
@ -133,41 +135,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
if (ante > user.balance)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
'on',
|
||||
question,
|
||||
'ante:',
|
||||
ante || 0
|
||||
)
|
||||
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
slug,
|
||||
user,
|
||||
question,
|
||||
outcomeType,
|
||||
description ?? {},
|
||||
initialProb ?? 0,
|
||||
ante,
|
||||
closeTime.getTime(),
|
||||
tags ?? [],
|
||||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0,
|
||||
isLogScale ?? false,
|
||||
answers ?? []
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
let group = null
|
||||
let group: Group | null = null
|
||||
if (groupId) {
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
|
@ -186,15 +154,68 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
'on',
|
||||
question,
|
||||
'ante:',
|
||||
ante || 0
|
||||
)
|
||||
|
||||
// convert string descriptions into JSONContent
|
||||
const newDescription =
|
||||
typeof description === 'string'
|
||||
? {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: description }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: description ?? {}
|
||||
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
slug,
|
||||
user,
|
||||
question,
|
||||
outcomeType,
|
||||
newDescription,
|
||||
initialProb ?? 0,
|
||||
ante,
|
||||
closeTime.getTime(),
|
||||
tags ?? [],
|
||||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0,
|
||||
isLogScale ?? false,
|
||||
answers ?? []
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
if (group != null) {
|
||||
if (!group.contractIds.includes(contractRef.id)) {
|
||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||
await groupDocRef.update({
|
||||
const groupDocRef = firestore.collection('groups').doc(group.id)
|
||||
groupDocRef.update({
|
||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const liquidityDoc = firestore
|
|
@ -16,7 +16,7 @@ import {
|
|||
cleanDisplayName,
|
||||
cleanUsername,
|
||||
} from '../../common/util/clean-username'
|
||||
import { sendWelcomeEmail } from './emails'
|
||||
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
||||
import { isWhitelisted } from '../../common/envs/constants'
|
||||
import {
|
||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
||||
|
@ -96,6 +96,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
|
||||
await addUserToDefaultGroups(user)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await sendPersonalFollowupEmail(user, privateUser)
|
||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||
|
||||
return { user, privateUser }
|
||||
|
|
|
@ -128,7 +128,20 @@
|
|||
<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; text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hi {{name}},</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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;">Thanks for
|
||||
using Manifold Markets. Running low
|
||||
|
@ -161,6 +174,51 @@
|
|||
</table>
|
||||
</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;">
|
||||
<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;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides making correct predictions, there are
|
||||
plenty of other ways to earn mana?</span></p>
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||
tips on comments</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||
trader bonus for each user who bets on your
|
||||
markets</span></li>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||
friends</u></span></a></span></li>
|
||||
<li style="line-height:23px;"><a class="link-build-content"
|
||||
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||
feedback</u></span></a></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;">
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -188,6 +188,56 @@
|
|||
</table>
|
||||
</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;">
|
||||
<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;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides betting and making predictions, you can also <a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/create"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;font-weight: bold;"><u>create
|
||||
your
|
||||
own
|
||||
market</u></span></a> on
|
||||
any question you care about?</span></p>
|
||||
|
||||
<p>More resources:</p>
|
||||
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/about"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Learn more</u></span></a>
|
||||
about Manifold and how our markets work</span></li>
|
||||
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
||||
your friends</u></span></a> and earn M$500 for each signup!</span></li>
|
||||
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://discord.com/invite/eHQBNBqXuh"><span
|
||||
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;">
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import * as dayjs from 'dayjs'
|
||||
|
||||
import { DOMAIN } from '../../common/envs/constants'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
@ -14,7 +16,7 @@ import {
|
|||
import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail } from './send-email'
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
|
@ -74,9 +76,8 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
// Modify template here:
|
||||
// https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial
|
||||
// Mailgun username: james@mantic.markets
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-resolved',
|
||||
|
@ -152,7 +153,7 @@ export const sendWelcomeEmail = async (
|
|||
const emailType = 'generic'
|
||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Welcome to Manifold Markets!',
|
||||
'welcome',
|
||||
|
@ -166,6 +167,43 @@ export const sendWelcomeEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const sendPersonalFollowupEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const emailBody = `Hi ${firstName},
|
||||
|
||||
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
|
||||
|
||||
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
||||
|
||||
Feel free to reply to this email with any questions or concerns you have.
|
||||
|
||||
Cheers,
|
||||
|
||||
James
|
||||
Cofounder of Manifold Markets
|
||||
https://manifold.markets
|
||||
`
|
||||
|
||||
const sendTime = dayjs().add(4, 'hours').toString()
|
||||
|
||||
await sendTextEmail(
|
||||
privateUser.email,
|
||||
'How are you finding Manifold?',
|
||||
emailBody,
|
||||
{
|
||||
from: 'James from Manifold <james@manifold.markets>',
|
||||
'o:deliverytime': sendTime,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendOneWeekBonusEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
@ -183,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
|
|||
const emailType = 'generic'
|
||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold Markets one week anniversary gift',
|
||||
'one-week',
|
||||
|
@ -198,6 +236,37 @@ export const sendOneWeekBonusEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const sendCreatorGuideEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
privateUser.unsubscribedFromGenericEmails
|
||||
)
|
||||
return
|
||||
|
||||
const { name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const emailType = 'generic'
|
||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Market creation guide',
|
||||
'creating-market',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendThankYouEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
@ -215,7 +284,7 @@ export const sendThankYouEmail = async (
|
|||
const emailType = 'generic'
|
||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Thanks for your Manifold purchase',
|
||||
'thank-you',
|
||||
|
@ -250,7 +319,7 @@ export const sendMarketCloseEmail = async (
|
|||
const emailType = 'market-resolve'
|
||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Your market has closed',
|
||||
'market-close',
|
||||
|
@ -309,7 +378,7 @@ export const sendNewCommentEmail = async (
|
|||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||
const answerNumber = `#${answerId}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-answer-comment',
|
||||
|
@ -332,7 +401,7 @@ export const sendNewCommentEmail = async (
|
|||
bet.outcome
|
||||
)}`
|
||||
}
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-comment',
|
||||
|
@ -377,7 +446,7 @@ export const sendNewAnswerEmail = async (
|
|||
const subject = `New answer on ${question}`
|
||||
const from = `${name} <info@manifold.markets>`
|
||||
|
||||
await sendTemplateEmail(
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-answer',
|
||||
|
|
|
@ -38,7 +38,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'
|
||||
|
@ -57,7 +57,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'
|
||||
|
|
|
@ -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'
|
||||
|
@ -24,7 +24,12 @@ export const onCreateCommentOnContract = functions
|
|||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with comment')
|
||||
|
||||
const comment = change.data() as Comment
|
||||
await change.ref.update({
|
||||
contractSlug: contract.slug,
|
||||
contractQuestion: contract.question,
|
||||
})
|
||||
|
||||
const comment = change.data() as ContractComment
|
||||
const lastCommentTime = comment.createdTime
|
||||
|
||||
const commentCreator = await getUser(comment.userId)
|
||||
|
@ -59,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,12 +1,17 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getPrivateUser, 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.firestore
|
||||
.document('contracts/{contractId}')
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('contracts/{contractId}')
|
||||
.onCreate(async (snapshot, context) => {
|
||||
const contract = snapshot.data() as Contract
|
||||
const { eventId } = context
|
||||
|
@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore
|
|||
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)
|
||||
}
|
||||
|
|
|
@ -30,15 +30,7 @@ const bodySchema = z.object({
|
|||
|
||||
const binarySchema = z.object({
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
limitProb: z
|
||||
.number()
|
||||
.gte(0.001)
|
||||
.lte(0.999)
|
||||
.refine(
|
||||
(p) => Math.round(p * 100) === p * 100,
|
||||
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
|
||||
)
|
||||
.optional(),
|
||||
limitProb: z.number().gte(0.001).lte(0.999).optional(),
|
||||
})
|
||||
|
||||
const freeResponseSchema = z.object({
|
||||
|
@ -89,7 +81,22 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
||||
mechanism == 'cpmm-1'
|
||||
) {
|
||||
const { outcome, limitProb } = validate(binarySchema, req.body)
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { outcome, limitProb } = validate(binarySchema, req.body)
|
||||
|
||||
if (limitProb !== undefined && outcomeType === 'BINARY') {
|
||||
const isRounded = floatingEqual(
|
||||
Math.round(limitProb * 100),
|
||||
limitProb * 100
|
||||
)
|
||||
if (!isRounded)
|
||||
throw new APIError(
|
||||
400,
|
||||
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
|
||||
)
|
||||
|
||||
limitProb = Math.round(limitProb * 100) / 100
|
||||
}
|
||||
|
||||
const unfilledBetsSnap = await trans.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
|
|
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.`)
|
||||
})
|
||||
}
|
70
functions/src/scripts/denormalize-comment-contract-data.ts
Normal file
70
functions/src/scripts/denormalize-comment-contract-data.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
// Filling in the contract-based fields on comments.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import {
|
||||
DocumentCorrespondence,
|
||||
findDiffs,
|
||||
describeDiff,
|
||||
applyDiff,
|
||||
} from './denormalize'
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function getContractsById(transaction: Transaction) {
|
||||
const contracts = await transaction.get(firestore.collection('contracts'))
|
||||
const results = Object.fromEntries(contracts.docs.map((doc) => [doc.id, doc]))
|
||||
console.log(`Found ${contracts.size} contracts.`)
|
||||
return results
|
||||
}
|
||||
|
||||
async function getCommentsByContractId(transaction: Transaction) {
|
||||
const comments = await transaction.get(
|
||||
firestore.collectionGroup('comments').where('contractId', '!=', null)
|
||||
)
|
||||
const results = new Map<string, DocumentSnapshot[]>()
|
||||
comments.forEach((doc) => {
|
||||
const contractId = doc.get('contractId')
|
||||
const contractComments = results.get(contractId) || []
|
||||
contractComments.push(doc)
|
||||
results.set(contractId, contractComments)
|
||||
})
|
||||
console.log(`Found ${comments.size} comments on ${results.size} contracts.`)
|
||||
return results
|
||||
}
|
||||
|
||||
async function denormalize() {
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
hasMore = await admin.firestore().runTransaction(async (transaction) => {
|
||||
const [contractsById, commentsByContractId] = await Promise.all([
|
||||
getContractsById(transaction),
|
||||
getCommentsByContractId(transaction),
|
||||
])
|
||||
const mapping = Object.entries(contractsById).map(
|
||||
([id, doc]): DocumentCorrespondence => {
|
||||
return [doc, commentsByContractId.get(id) || []]
|
||||
}
|
||||
)
|
||||
const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug')
|
||||
const qDiffs = findDiffs(mapping, 'question', 'contractQuestion')
|
||||
console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`)
|
||||
console.log(`Found ${qDiffs.length} comments with mismatched questions.`)
|
||||
const diffs = slugDiffs.concat(qDiffs)
|
||||
diffs.slice(0, 500).forEach((d) => {
|
||||
console.log(describeDiff(d))
|
||||
applyDiff(transaction, d)
|
||||
})
|
||||
if (diffs.length > 500) {
|
||||
console.log(`Applying first 500 because of Firestore limit...`)
|
||||
}
|
||||
return diffs.length > 500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
denormalize().catch((e) => console.error(e))
|
||||
}
|
|
@ -1,27 +1,35 @@
|
|||
import * as mailgun from 'mailgun-js'
|
||||
import { tryOrLogError } from './utils'
|
||||
|
||||
const initMailgun = () => {
|
||||
const apiKey = process.env.MAILGUN_KEY as string
|
||||
return mailgun({ apiKey, domain: 'mg.manifold.markets' })
|
||||
}
|
||||
|
||||
export const sendTextEmail = (to: string, subject: string, text: string) => {
|
||||
export const sendTextEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
text: string,
|
||||
options?: Partial<mailgun.messages.SendData>
|
||||
) => {
|
||||
const data: mailgun.messages.SendData = {
|
||||
from: 'Manifold Markets <info@manifold.markets>',
|
||||
...options,
|
||||
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
// Don't rewrite urls in plaintext emails
|
||||
'o:tracking-clicks': 'htmlonly',
|
||||
}
|
||||
const mg = initMailgun()
|
||||
return mg.messages().send(data, (error) => {
|
||||
if (error) console.log('Error sending email', error)
|
||||
else console.log('Sent text email', to, subject)
|
||||
})
|
||||
const mg = initMailgun().messages()
|
||||
const result = await tryOrLogError(mg.send(data))
|
||||
if (result != null) {
|
||||
console.log('Sent text email', to, subject)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const sendTemplateEmail = (
|
||||
export const sendTemplateEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
templateId: string,
|
||||
|
@ -38,10 +46,10 @@ export const sendTemplateEmail = (
|
|||
'o:tag': templateId,
|
||||
'o:tracking': true,
|
||||
}
|
||||
const mg = initMailgun()
|
||||
|
||||
return mg.messages().send(data, (error) => {
|
||||
if (error) console.log('Error sending email', error)
|
||||
else console.log('Sent template email', templateId, to, subject)
|
||||
})
|
||||
const mg = initMailgun().messages()
|
||||
const result = await tryOrLogError(mg.send(data))
|
||||
if (result != null) {
|
||||
console.log('Sent template email', templateId, to, subject)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -42,6 +42,15 @@ export const writeAsync = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const tryOrLogError = async <T>(task: Promise<T>) => {
|
||||
try {
|
||||
return await task
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const isProd = () => {
|
||||
return admin.instanceId().app.options.projectId === 'mantic-markets'
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ module.exports = {
|
|||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@next/next/recommended',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -448,8 +448,6 @@ function LimitOrderPanel(props: {
|
|||
const yesAmount = shares * (yesLimitProb ?? 1)
|
||||
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||
|
||||
const profitIfBothFilled = shares - (yesAmount + noAmount)
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
setWasSubmitted(false)
|
||||
setBetAmount(newAmount)
|
||||
|
@ -559,6 +557,8 @@ function LimitOrderPanel(props: {
|
|||
)
|
||||
const noReturnPercent = formatPercent(noReturn)
|
||||
|
||||
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
|
||||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<Row className="mt-1 items-center gap-4">
|
||||
|
|
|
@ -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) => {
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
message: newChallenge.message,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: contract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: contract.id,
|
||||
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,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: challengeContract as BinaryContract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: challengeContract.id,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("couldn't create market/challenge:", e)
|
||||
}
|
||||
setLoading(false)
|
||||
}}
|
||||
challengeSlug={challengeSlug}
|
||||
/>
|
||||
|
@ -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{' '}
|
||||
<span className="underline">{contract.question}</span>
|
||||
{contract ? (
|
||||
<span className="underline">{contract.question}</span>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="e.g. Will a Democrat be the next president?"
|
||||
className="input input-bordered mt-1 w-full resize-none"
|
||||
autoFocus={true}
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={challengeInfo.question}
|
||||
onChange={(e) =>
|
||||
setChallengeInfo({
|
||||
...challengeInfo,
|
||||
question: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
|
@ -187,22 +236,23 @@ function CreateChallengeForm(props: {
|
|||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
{contract && (
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
|
@ -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,12 +1,8 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Dictionary, keyBy, uniq } from 'lodash'
|
||||
|
||||
import { Comment } from 'common/comment'
|
||||
import { Contract } from 'common/contract'
|
||||
import { filterDefined, groupConsecutive } from 'common/util/array'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { Comment, ContractComment } from 'common/comment'
|
||||
import { groupConsecutive } from 'common/util/array'
|
||||
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||
import { getContractFromId } from 'web/lib/firebase/contracts'
|
||||
import { SiteLink } from './site-link'
|
||||
import { Row } from './layout/row'
|
||||
import { Avatar } from './avatar'
|
||||
|
@ -20,12 +16,15 @@ import { LoadingIndicator } from './loading-indicator'
|
|||
|
||||
const COMMENTS_PER_PAGE = 50
|
||||
|
||||
type ContractComment = Comment & { contractId: 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/
|
||||
return `/market/${slug}`
|
||||
}
|
||||
|
||||
export function UserCommentsList(props: { user: User }) {
|
||||
const { user } = props
|
||||
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||
const [contracts, setContracts] = useState<Dictionary<Contract> | undefined>()
|
||||
const [page, setPage] = useState(0)
|
||||
const start = page * COMMENTS_PER_PAGE
|
||||
const end = start + COMMENTS_PER_PAGE
|
||||
|
@ -33,38 +32,29 @@ 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (comments) {
|
||||
const contractIds = uniq(comments.map((c) => c.contractId))
|
||||
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
|
||||
setContracts(keyBy(filterDefined(contracts), 'id'))
|
||||
})
|
||||
}
|
||||
}, [comments])
|
||||
|
||||
if (comments == null || contracts == null) {
|
||||
if (comments == null) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
||||
const pageComments = groupConsecutive(
|
||||
comments.slice(start, end),
|
||||
(c) => c.contractId
|
||||
)
|
||||
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
|
||||
return { question: c.contractQuestion, slug: c.contractSlug }
|
||||
})
|
||||
return (
|
||||
<Col className={'bg-white'}>
|
||||
{pageComments.map(({ key, items }, i) => {
|
||||
const contract = contracts[key]
|
||||
return (
|
||||
<div key={start + i} className="border-b p-5">
|
||||
<SiteLink
|
||||
className="mb-2 block pb-2 font-medium text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
href={contractPath(key.slug)}
|
||||
>
|
||||
{contract.question}
|
||||
{key.question}
|
||||
</SiteLink>
|
||||
<Col className="gap-6">
|
||||
{items.map((comment) => (
|
||||
|
|
|
@ -83,7 +83,7 @@ export function ContractSearch(props: {
|
|||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
hideOrderSelector?: boolean
|
||||
gridClassName?: string
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
|
@ -91,6 +91,7 @@ export function ContractSearch(props: {
|
|||
headerClassName?: string
|
||||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
isWholePage?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -98,13 +99,14 @@ export function ContractSearch(props: {
|
|||
defaultFilter,
|
||||
additionalFilter,
|
||||
onContractClick,
|
||||
gridClassName,
|
||||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
headerClassName,
|
||||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
isWholePage,
|
||||
} = props
|
||||
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
|
@ -139,7 +141,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 +183,7 @@ export function ContractSearch(props: {
|
|||
loadMore={performQuery}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
gridClassName={gridClassName}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
|
|
|
@ -122,19 +122,6 @@ export function ContractCard(props: {
|
|||
) : (
|
||||
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
||||
))}
|
||||
<Row className={'absolute bottom-3 gap-2 md:gap-0'}>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
short={true}
|
||||
className={'block md:hidden'}
|
||||
/>
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
<QuickBet contract={contract} user={user} />
|
||||
|
@ -172,6 +159,24 @@ export function ContractCard(props: {
|
|||
<ProbBar contract={contract} />
|
||||
</>
|
||||
)}
|
||||
<Row
|
||||
className={clsx(
|
||||
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
|
||||
showQuickBet ? 'w-[85%]' : 'w-full'
|
||||
)}
|
||||
>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
short={true}
|
||||
className={'block md:hidden'}
|
||||
/>
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -58,13 +58,13 @@ export function MiscDetails(props: {
|
|||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-3 text-sm text-gray-400">
|
||||
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showTime === 'close-date' ? (
|
||||
<Row className="gap-0.5">
|
||||
<Row className="gap-0.5 whitespace-nowrap">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,7 +20,7 @@ export function ContractsGrid(props: {
|
|||
loadMore?: () => void
|
||||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
gridClassName?: string
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
|
@ -32,7 +32,7 @@ export function ContractsGrid(props: {
|
|||
showTime,
|
||||
loadMore,
|
||||
onContractClick,
|
||||
gridClassName,
|
||||
overrideGridClassName,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
|
@ -66,8 +66,9 @@ export function ContractsGrid(props: {
|
|||
<Col className="gap-8">
|
||||
<ul
|
||||
className={clsx(
|
||||
'w-full columns-1 gap-4 space-y-4 md:columns-2',
|
||||
gridClassName
|
||||
overrideGridClassName
|
||||
? overrideGridClassName
|
||||
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{contracts.map((contract) => (
|
||||
|
@ -80,10 +81,11 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
className={clsx(
|
||||
'break-inside-avoid-column',
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
)}
|
||||
className={
|
||||
contractIds?.includes(contract.id)
|
||||
? highlightClassName
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -65,7 +65,9 @@ export function MarketModal(props: {
|
|||
<ContractSearch
|
||||
hideOrderSelector
|
||||
onContractClick={addContract}
|
||||
gridClassName="gap-3 space-y-3"
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
@ -353,7 +354,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
|||
elementId={id}
|
||||
/>
|
||||
</Row>
|
||||
<div className="mt-2 text-black">
|
||||
<div className="mt-2 text-base text-black">
|
||||
{comments.map((comment) => (
|
||||
<Content
|
||||
key={comment.id}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -58,8 +58,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()
|
||||
|
@ -103,10 +101,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,19 +114,22 @@ export function UserPage(props: { user: User }) {
|
|||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<Row className={'items-center gap-2'}>
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<span className="mt-1 text-gray-500">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>{' '}
|
||||
profit
|
||||
</span>
|
||||
</Row>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
<span className="text-gray-500">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>{' '}
|
||||
profit
|
||||
</span>
|
||||
|
||||
<Spacer h={4} />
|
||||
{user.bio && (
|
||||
<>
|
||||
|
@ -138,17 +139,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 +189,7 @@ export function UserPage(props: { user: User }) {
|
|||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={5} />
|
||||
{currentUser?.id === user.id && (
|
||||
<Row
|
||||
|
@ -208,7 +199,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 +235,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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -22,7 +22,6 @@ 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'
|
||||
|
@ -285,16 +284,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, {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
const API_DOCS_URL = 'https://docs.manifold.markets/api'
|
||||
|
||||
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
staticPageGenerationTimeout: 600, // e.g. stats page
|
||||
|
@ -35,6 +37,11 @@ module.exports = {
|
|||
destination: API_DOCS_URL,
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/about',
|
||||
destination: ABOUT_PAGE_URL,
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/analytics',
|
||||
destination: '/stats',
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"next-sitemap": "^2.5.14",
|
||||
"postcss": "8.3.5",
|
||||
"prettier-plugin-tailwindcss": "^0.1.5",
|
||||
|
|
|
@ -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'
|
||||
|
@ -38,6 +38,7 @@ 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'
|
||||
|
@ -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
|
||||
|
|
|
@ -7,6 +7,7 @@ import { User } from 'common/user'
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
|
||||
export type LiteMarket = {
|
||||
// Unique identifer for this market
|
||||
|
@ -22,6 +23,7 @@ export type LiteMarket = {
|
|||
closeTime?: number
|
||||
question: string
|
||||
description: string | JSONContent
|
||||
textDescription: string // string version of description
|
||||
tags: string[]
|
||||
url: string
|
||||
outcomeType: string
|
||||
|
@ -40,6 +42,8 @@ export type LiteMarket = {
|
|||
resolution?: string
|
||||
resolutionTime?: number
|
||||
resolutionProbability?: number
|
||||
|
||||
lastUpdatedTime?: number
|
||||
}
|
||||
|
||||
export type ApiAnswer = Answer & {
|
||||
|
@ -90,6 +94,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
lastUpdatedTime,
|
||||
} = contract
|
||||
|
||||
const { p, totalLiquidity } = contract as any
|
||||
|
@ -97,6 +102,11 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
const probability =
|
||||
contract.outcomeType === 'BINARY' ? getProbability(contract) : undefined
|
||||
|
||||
let min, max, isLogScale: any
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
;({ min, max, isLogScale } = contract)
|
||||
}
|
||||
|
||||
return removeUndefinedProps({
|
||||
id,
|
||||
creatorUsername,
|
||||
|
@ -109,6 +119,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
: closeTime,
|
||||
question,
|
||||
description,
|
||||
textDescription:
|
||||
typeof description === 'string'
|
||||
? description
|
||||
: richTextToString(description),
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
pool,
|
||||
|
@ -124,6 +138,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
lastUpdatedTime,
|
||||
min,
|
||||
max,
|
||||
isLogScale,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
@ -607,7 +607,9 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
user={user}
|
||||
hideOrderSelector={true}
|
||||
onContractClick={addContractToCurrentGroup}
|
||||
gridClassName="gap-3 space-y-3"
|
||||
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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -18,7 +18,9 @@ online = false
|
|||
firstPrint = false
|
||||
flag = true
|
||||
page = 1
|
||||
sets = {}
|
||||
|
||||
window.console.log(sets)
|
||||
document.location.search.split('&').forEach((pair) => {
|
||||
let v = pair.split('=')
|
||||
if (v[0] === '?whichguesser') {
|
||||
|
@ -32,39 +34,38 @@ document.location.search.split('&').forEach((pair) => {
|
|||
}
|
||||
})
|
||||
|
||||
let firstFetch = fetch('jsons/' + whichGuesser + page + '.json')
|
||||
if (whichGuesser === 'basic') {
|
||||
fetch('jsons/set.json')
|
||||
.then((response) => response.json())
|
||||
.then((data) => (sets = data))
|
||||
}
|
||||
|
||||
let firstFetch = fetch('jsons/' + whichGuesser + '.json')
|
||||
fetchToResponse(firstFetch)
|
||||
|
||||
function putIntoMapAndFetch(data) {
|
||||
putIntoMap(data.data)
|
||||
if (data.has_more) {
|
||||
page += 1
|
||||
window.setTimeout(() =>
|
||||
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json'))
|
||||
for (const [key, value] of Object.entries(allData)) {
|
||||
nameList.push(key)
|
||||
probList.push(
|
||||
value.length + (probList.length === 0 ? 0 : probList[probList.length - 1])
|
||||
)
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(allData)) {
|
||||
nameList.push(key)
|
||||
probList.push(
|
||||
value.length +
|
||||
(probList.length === 0 ? 0 : probList[probList.length - 1])
|
||||
)
|
||||
unseenTotal = total
|
||||
}
|
||||
window.console.log(allData)
|
||||
window.console.log(total)
|
||||
window.console.log(probList)
|
||||
window.console.log(nameList)
|
||||
if (whichGuesser === 'counterspell') {
|
||||
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
|
||||
} else if (whichGuesser === 'burn') {
|
||||
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
|
||||
} else if (whichGuesser === 'beast') {
|
||||
document.getElementById('guess-type').innerText =
|
||||
'Finding Fantastic Beasts'
|
||||
}
|
||||
setUpNewGame()
|
||||
unseenTotal = total
|
||||
}
|
||||
window.console.log(allData)
|
||||
window.console.log(total)
|
||||
window.console.log(probList)
|
||||
window.console.log(nameList)
|
||||
if (whichGuesser === 'counterspell') {
|
||||
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
|
||||
} else if (whichGuesser === 'burn') {
|
||||
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
|
||||
} else if (whichGuesser === 'beast') {
|
||||
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
|
||||
} else if (whichGuesser === 'basic') {
|
||||
document.getElementById('guess-type').innerText = 'How Basic'
|
||||
}
|
||||
setUpNewGame()
|
||||
}
|
||||
|
||||
function getKSamples() {
|
||||
|
@ -134,11 +135,21 @@ function determineIfSkip(card) {
|
|||
}
|
||||
}
|
||||
if (firstPrint) {
|
||||
if (
|
||||
card.reprint === true ||
|
||||
(card.frame_effects && card.frame_effects.includes('showcase'))
|
||||
) {
|
||||
return true
|
||||
if (whichGuesser == 'basic') {
|
||||
if (
|
||||
card.set_type !== 'expansion' &&
|
||||
card.set_type !== 'funny' &&
|
||||
card.set_type !== 'draft_innovation'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
card.reprint === true ||
|
||||
(card.frame_effects && card.frame_effects.includes('showcase'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// reskinned card names show in art crop
|
||||
|
@ -160,13 +171,16 @@ function putIntoMap(data) {
|
|||
if (card.card_faces) {
|
||||
name = card.card_faces[0].name
|
||||
}
|
||||
if (whichGuesser === 'basic') {
|
||||
name =
|
||||
'<img class="symbol" style="width: 17px; height: 17px" src="' +
|
||||
sets[name][1] +
|
||||
'" /> ' +
|
||||
sets[name][0]
|
||||
}
|
||||
let normalImg = ''
|
||||
if (card.image_uris.normal) {
|
||||
normalImg = card.image_uris.normal
|
||||
} else if (card.image_uris.large) {
|
||||
normalImg = card.image_uris.large
|
||||
} else if (card.image_uris.small) {
|
||||
normalImg = card.image_uris.small
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
@ -211,7 +225,9 @@ function setUpNewGame() {
|
|||
artDict = sampledData[0]
|
||||
let randomImages = Object.keys(artDict)
|
||||
shuffleArray(randomImages)
|
||||
let namesList = Array.from(sampledData[1]).sort()
|
||||
let namesList = Array.from(sampledData[1]).sort((a, b) =>
|
||||
removeSymbol(a).localeCompare(removeSymbol(b))
|
||||
)
|
||||
// fill in the new cards and names
|
||||
for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
|
||||
let currCard = document.getElementById('card-' + cardIndex)
|
||||
|
@ -224,11 +240,16 @@ function setUpNewGame() {
|
|||
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
|
||||
currName = document.getElementById('name-' + nameIndex)
|
||||
// window.console.log(currName)
|
||||
currName.innerText = namesList[nameIndex - 1]
|
||||
currName.innerHTML = namesList[nameIndex - 1]
|
||||
nameBank.appendChild(currName)
|
||||
}
|
||||
}
|
||||
|
||||
function removeSymbol(name) {
|
||||
let arr = name.split('>')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
function checkAnswers() {
|
||||
let score = k
|
||||
// show the correct full cards
|
||||
|
@ -236,9 +257,13 @@ function checkAnswers() {
|
|||
currCard = document.getElementById('card-' + cardIndex)
|
||||
let incorrect = true
|
||||
if (currCard.dataset.name) {
|
||||
let guess = document.getElementById(currCard.dataset.name).innerText
|
||||
// window.console.log(artDict[currCard.dataset.url][0], guess);
|
||||
incorrect = artDict[currCard.dataset.url][0] !== guess
|
||||
// remove image text
|
||||
let guess = removeSymbol(
|
||||
document.getElementById(currCard.dataset.name).innerText
|
||||
)
|
||||
let ans = removeSymbol(artDict[currCard.dataset.url][0])
|
||||
window.console.log(ans, guess)
|
||||
incorrect = ans !== guess
|
||||
// decide if their guess was correct
|
||||
}
|
||||
if (incorrect) currCard.classList.add('incorrect')
|
||||
|
@ -352,6 +377,10 @@ function dropOnCard(id, data) {
|
|||
}
|
||||
|
||||
function setWordsLeft() {
|
||||
cardName = 'Unused Card Names: '
|
||||
if (whichGuesser === 'basic') {
|
||||
cardName = 'Unused Set Names: '
|
||||
}
|
||||
document.getElementById('words-left').innerText =
|
||||
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft
|
||||
cardName + wordsLeft + '/Images: ' + imagesLeft
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
}
|
||||
|
||||
.answer-page .card {
|
||||
height: 350px;
|
||||
height: 353px;
|
||||
/*padding-top: 310px;*/
|
||||
/*background-size: cover;*/
|
||||
overflow: hidden;
|
||||
|
@ -253,16 +253,6 @@
|
|||
.name {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 300px;
|
||||
background-size: 300px;
|
||||
height: 266px;
|
||||
}
|
||||
|
||||
.answer-page .card {
|
||||
height: 454px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
@ -3,7 +3,8 @@ import requests
|
|||
import json
|
||||
|
||||
# add category name here
|
||||
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn']
|
||||
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
|
||||
specialCategories = ['set', 'basic']
|
||||
|
||||
|
||||
def generate_initial_query(category):
|
||||
|
@ -12,11 +13,11 @@ def generate_initial_query(category):
|
|||
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
|
||||
elif category == 'beast':
|
||||
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
|
||||
elif category == 'terror':
|
||||
string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||
'%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||
elif category == 'wrath':
|
||||
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
|
||||
# elif category == 'terror':
|
||||
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||
# elif category == 'wrath':
|
||||
# string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
|
||||
elif category == 'burn':
|
||||
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
|
||||
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
|
||||
|
@ -24,9 +25,19 @@ def generate_initial_query(category):
|
|||
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
|
||||
# add category string query here
|
||||
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
|
||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
||||
'+-frame%3Aextendedart+language%3Aenglish&unique=art&page='
|
||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
||||
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
|
||||
print(string_query)
|
||||
return string_query
|
||||
|
||||
def generate_initial_special_query(category):
|
||||
string_query = 'https://api.scryfall.com/cards/search?q='
|
||||
if category == 'set':
|
||||
return 'https://api.scryfall.com/sets'
|
||||
elif category == 'basic':
|
||||
string_query += 't%3Abasic&order=released&dir=asc&unique=prints&page='
|
||||
# add category string query here
|
||||
print(string_query)
|
||||
return string_query
|
||||
|
||||
|
@ -34,31 +45,60 @@ def generate_initial_query(category):
|
|||
def fetch_and_write_all(category, query):
|
||||
count = 1
|
||||
will_repeat = True
|
||||
all_cards = {'data' : []}
|
||||
art_names = set()
|
||||
while will_repeat:
|
||||
will_repeat = fetch_and_write(category, query, count)
|
||||
count += 1
|
||||
response = fetch(query, count)
|
||||
will_repeat = response['has_more']
|
||||
count+=1
|
||||
to_compact_write_form(all_cards, art_names, response, category)
|
||||
|
||||
with open('jsons/' + category + '.json', 'w') as f:
|
||||
json.dump(all_cards, f)
|
||||
|
||||
|
||||
def fetch_and_write(category, query, count):
|
||||
def fetch_and_write_all_special(category, query):
|
||||
count = 1
|
||||
will_repeat = True
|
||||
all_cards = {'data' : []}
|
||||
art_names = set()
|
||||
while will_repeat:
|
||||
if category == 'set':
|
||||
response = fetch_special(query)
|
||||
else:
|
||||
response = fetch(query, count)
|
||||
will_repeat = response['has_more']
|
||||
count+=1
|
||||
to_compact_write_form_special(all_cards, art_names, response, category)
|
||||
|
||||
with open('jsons/' + category + '.json', 'w') as f:
|
||||
json.dump(all_cards, f)
|
||||
|
||||
|
||||
|
||||
def fetch(query, count):
|
||||
query += str(count)
|
||||
response = requests.get(f"{query}").json()
|
||||
time.sleep(0.1)
|
||||
with open('jsons/' + category + str(count) + '.json', 'w') as f:
|
||||
json.dump(to_compact_write_form(response), f)
|
||||
return response['has_more']
|
||||
return response
|
||||
|
||||
def fetch_special(query):
|
||||
response = requests.get(f"{query}").json()
|
||||
time.sleep(0.1)
|
||||
return response
|
||||
|
||||
|
||||
def to_compact_write_form(response):
|
||||
fieldsToUse = ['has_more']
|
||||
def to_compact_write_form(smallJson, art_names, response, category):
|
||||
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
|
||||
'set_type']
|
||||
smallJson = dict()
|
||||
data = []
|
||||
# write all fields needed in response
|
||||
for field in fieldsToUse:
|
||||
smallJson[field] = response[field]
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
# do not repeat art
|
||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card['illustration_id'])
|
||||
write_card = dict()
|
||||
for field in fieldsInCard:
|
||||
if field == 'name' and 'card_faces' in card:
|
||||
|
@ -68,8 +108,33 @@ def to_compact_write_form(response):
|
|||
elif field in card:
|
||||
write_card[field] = card[field]
|
||||
data.append(write_card)
|
||||
smallJson['data'] = data
|
||||
return smallJson
|
||||
smallJson['data'] += data
|
||||
|
||||
def to_compact_write_form_special(smallJson, art_names, response, category):
|
||||
fieldsInBasic = ['image_uris', 'set', 'set_type', 'digital']
|
||||
data = []
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
if category == 'basic':
|
||||
write_card = dict()
|
||||
# do not repeat art
|
||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card['illustration_id'])
|
||||
for field in fieldsInBasic:
|
||||
if field == 'image_uris':
|
||||
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
||||
elif field == 'set':
|
||||
write_card['name'] = card['set']
|
||||
elif field in card:
|
||||
write_card[field] = card[field]
|
||||
data.append(write_card)
|
||||
else:
|
||||
if card['set_type'] != 'token':
|
||||
smallJson[card['code']] = [card['name'],card['icon_svg_uri']]
|
||||
|
||||
smallJson['data'] += data
|
||||
|
||||
|
||||
# only write images needed
|
||||
|
@ -87,6 +152,9 @@ def write_image_uris(card_image_uris):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for category in allCategories:
|
||||
# for category in allCategories:
|
||||
# print(category)
|
||||
# fetch_and_write_all(category, generate_initial_query(category))
|
||||
for category in specialCategories:
|
||||
print(category)
|
||||
fetch_and_write_all(category, generate_initial_query(category))
|
||||
fetch_and_write_all_special(category, generate_initial_special_query(category))
|
||||
|
|
|
@ -159,6 +159,16 @@
|
|||
>
|
||||
<br />
|
||||
|
||||
<input type="radio" id="basic" name="whichguesser" value="basic" />
|
||||
<label class="radio-label" for="basic">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
|
||||
/>
|
||||
<h3>How Basic</h3></label
|
||||
>
|
||||
<br />
|
||||
|
||||
<details id="addl-options">
|
||||
<summary>
|
||||
<img
|
||||
|
@ -174,7 +184,7 @@
|
|||
<label for="un">include un-cards</label>
|
||||
<br />
|
||||
<input type="checkbox" name="original" id="original" />
|
||||
<label for="original">restrict to only original printing</label>
|
||||
<label for="original">only original set printing</label>
|
||||
</details>
|
||||
<input type="submit" id="submit" value="Play" />
|
||||
</form>
|
||||
|
|
1
web/public/mtg/jsons/basic.json
Normal file
1
web/public/mtg/jsons/basic.json
Normal file
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/beast.json
Normal file
1
web/public/mtg/jsons/beast.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/burn.json
Normal file
1
web/public/mtg/jsons/burn.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/counterspell.json
Normal file
1
web/public/mtg/jsons/counterspell.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/set.json
Normal file
1
web/public/mtg/jsons/set.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||
</urlset>
|
||||
|
|
|
@ -5764,6 +5764,11 @@ eslint-config-next@12.1.6:
|
|||
eslint-plugin-react "^7.29.4"
|
||||
eslint-plugin-react-hooks "^4.5.0"
|
||||
|
||||
eslint-config-prettier@8.5.0:
|
||||
version "8.5.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1"
|
||||
integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==
|
||||
|
||||
eslint-import-resolver-node@^0.3.6:
|
||||
version "0.3.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd"
|
||||
|
|
Loading…
Reference in New Issue
Block a user