Merge branch 'main' into loans2

This commit is contained in:
James Grugett 2022-08-19 11:55:42 -05:00
commit d79783ea25
90 changed files with 1420 additions and 1620 deletions

View File

@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people.
Reduce the number of easily preventable deaths worldwide. Reduce the number of easily preventable deaths worldwide.
Work towards sustainable, systemic change.`, Work towards sustainable, systemic change.`,
}, },
{
name: 'YIMBY Law',
website: 'https://www.yimbylaw.org/',
photo: 'https://i.imgur.com/zlzp21Z.png',
preview:
'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.',
description: `
YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply.
If you would like to support our work, you can do so by getting involved or by donating.`,
},
{
name: 'CaRLA',
website: 'https://carlaef.org/',
photo: 'https://i.imgur.com/IsNVTOY.png',
preview:
'The California Renters Legal Advocacy and Education Funds core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.',
description: `
The California Renters Legal Advocacy and Education Funds core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.
CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the states housing shortage.
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
},
].map((charity) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -1,13 +1,11 @@
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
export type Comment = { export type Comment<T extends AnyCommentType = AnyCommentType> = {
id: string id: string
contractId?: string
groupId?: string
betId?: string
answerOutcome?: string
replyToCommentId?: string replyToCommentId?: string
userId: string userId: string
@ -20,4 +18,21 @@ export type Comment = {
userName: string userName: string
userUsername: string userUsername: string
userAvatarUrl?: 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>

View File

@ -1,187 +0,0 @@
import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
import { Bet } from './bet'
import { Contract } from './contract'
import { ClickEvent } from './tracking'
import { filterDefined } from './util/array'
import { addObjects } from './util/object'
export const MAX_FEED_CONTRACTS = 75
export const getRecommendedContracts = (
contractsById: { [contractId: string]: Contract },
yourBetOnContractIds: string[]
) => {
const contracts = Object.values(contractsById)
const yourContracts = filterDefined(
yourBetOnContractIds.map((contractId) => contractsById[contractId])
)
const yourContractIds = new Set(yourContracts.map((c) => c.id))
const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id))
const yourWordFrequency = contractsToWordFrequency(yourContracts)
const otherWordFrequency = contractsToWordFrequency(notYourContracts)
const words = union(
Object.keys(yourWordFrequency),
Object.keys(otherWordFrequency)
)
const yourWeightedFrequency = Object.fromEntries(
words.map((word) => {
const [yourFreq, otherFreq] = [
yourWordFrequency[word] ?? 0,
otherWordFrequency[word] ?? 0,
]
const score = yourFreq / (yourFreq + otherFreq + 0.0001)
return [word, score]
})
)
// console.log(
// 'your weighted frequency',
// _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq)
// )
const scoredContracts = contracts.map((contract) => {
const wordFrequency = contractToWordFrequency(contract)
const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0
const weight = yourWeightedFrequency[word] ?? 0
return wordFreq * weight
})
return {
contract,
score,
}
})
return sortBy(scoredContracts, (scored) => -scored.score).map(
(scored) => scored.contract
)
}
const contractToText = (contract: Contract) => {
const { description, question, tags, creatorUsername } = contract
return `${creatorUsername} ${question} ${tags.join(' ')} ${description}`
}
const MAX_CHARS_IN_WORD = 100
const getWordsCount = (text: string) => {
const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase()
const words = normalizedText
.split(' ')
.filter((word) => word)
.filter((word) => word.length <= MAX_CHARS_IN_WORD)
const counts: { [word: string]: number } = {}
for (const word of words) {
if (counts[word]) counts[word]++
else counts[word] = 1
}
return counts
}
const toFrequency = (counts: { [word: string]: number }) => {
const total = sum(Object.values(counts))
return mapValues(counts, (count) => count / total)
}
const contractToWordFrequency = (contract: Contract) =>
toFrequency(getWordsCount(contractToText(contract)))
const contractsToWordFrequency = (contracts: Contract[]) => {
const frequencySum = contracts
.map(contractToWordFrequency)
.reduce(addObjects, {})
return toFrequency(frequencySum)
}
export const getWordScores = (
contracts: Contract[],
contractViewCounts: { [contractId: string]: number },
clicks: ClickEvent[],
bets: Bet[]
) => {
const contractClicks = groupBy(clicks, (click) => click.contractId)
const contractBets = groupBy(bets, (bet) => bet.contractId)
const yourContracts = contracts.filter(
(c) =>
contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id]
)
const yourTfIdf = calculateContractTfIdf(yourContracts)
const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
const viewCount = contractViewCounts[contractId] ?? 0
const clickCount = contractClicks[contractId]?.length ?? 0
const betCount = contractBets[contractId]?.length ?? 0
const factor =
-1 * Math.log(viewCount + 1) +
10 * Math.log(betCount + clickCount / 4 + 1)
return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
})
const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
const minScore = Math.min(...Object.values(wordScores))
const maxScore = Math.max(...Object.values(wordScores))
const normalizedWordScores = mapValues(
wordScores,
(score) => (score - minScore) / (maxScore - minScore)
)
// console.log(
// 'your word scores',
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100),
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100)
// )
return normalizedWordScores
}
export function getContractScore(
contract: Contract,
wordScores: { [word: string]: number }
) {
if (Object.keys(wordScores).length === 0) return 1
const wordFrequency = contractToWordFrequency(contract)
const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0
const weight = wordScores[word] ?? 0
return wordFreq * weight
})
return score
}
// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF):
// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736
function calculateContractTfIdf(contracts: Contract[]) {
const contractFreq = contracts.map((c) => contractToWordFrequency(c))
const contractWords = contractFreq.map((freq) => Object.keys(freq))
const wordsCount: { [word: string]: number } = {}
for (const words of contractWords) {
for (const word of words) {
wordsCount[word] = (wordsCount[word] ?? 0) + 1
}
}
const wordIdf = mapValues(wordsCount, (count) =>
Math.log(contracts.length / count)
)
const contractWordsTfIdf = contractFreq.map((wordFreq) =>
mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
)
return Object.fromEntries(
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
)
}

View File

@ -1,3 +1,5 @@
import { isEqual } from 'lodash'
export function filterDefined<T>(array: (T | null | undefined)[]) { export function filterDefined<T>(array: (T | null | undefined)[]) {
return array.filter((item) => item !== null && item !== undefined) as T[] 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]] } let curr = { key: key(xs[0]), items: [xs[0]] }
for (const x of xs.slice(1)) { for (const x of xs.slice(1)) {
const k = key(x) const k = key(x)
if (k !== curr.key) { if (!isEqual(key, curr.key)) {
result.push(curr) result.push(curr)
curr = { key: k, items: [x] } curr = { key: k, items: [x] }
} else { } else {

View File

@ -135,7 +135,8 @@ Requires no authorization.
// Market attributes. All times are in milliseconds since epoch // Market attributes. All times are in milliseconds since epoch
closeTime?: number // Min of creator's chosen date, and resolutionTime closeTime?: number // Min of creator's chosen date, and resolutionTime
question: string 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. // 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. // This list also includes the predefined categories shown as filters on the home page.
@ -162,6 +163,8 @@ Requires no authorization.
resolutionTime?: number resolutionTime?: number
resolution?: string resolution?: string
resolutionProbability?: number // Used for BINARY markets resolved to MKT 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`. - `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
- `question`: Required. The headline question for the market. - `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules 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. - `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. - `tags`: Optional. An array of string tags for the market.

View File

@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API
## Bots ## Bots

View File

@ -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", "collectionGroup": "comments",
"fieldPath": "createdTime", "fieldPath": "createdTime",

View File

@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet'
import { createChallengeAcceptedNotification } from './create-notification' import { createChallengeAcceptedNotification } from './create-notification'
import { noFees } from '../../common/fees' import { noFees } from '../../common/fees'
import { formatMoney, formatPercent } from '../../common/util/format' import { formatMoney, formatPercent } from '../../common/util/format'
import { redeemShares } from './redeem-shares'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => {
return yourNewBetDoc return yourNewBetDoc
}) })
await redeemShares(auth.uid, contractId)
return { betId: result.id } return { betId: result.id }
}) })

View File

@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node'
import { DEV_CONFIG } from '../../common/envs/dev' import { DEV_CONFIG } from '../../common/envs/dev'
import { PROD_CONFIG } from '../../common/envs/prod' 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 const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
@ -15,10 +15,12 @@ export const track = async (
eventProperties?: any, eventProperties?: any,
amplitudeProperties?: Partial<Amplitude.Event> amplitudeProperties?: Partial<Amplitude.Event>
) => { ) => {
await amp.logEvent({ return await tryOrLogError(
event_type: eventName, amp.logEvent({
user_id: userId, event_type: eventName,
event_properties: eventProperties, user_id: userId,
...amplitudeProperties, event_properties: eventProperties,
}) ...amplitudeProperties,
})
)
} }

View File

@ -14,15 +14,17 @@ import {
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' 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 { APIError, newEndpoint, validate, zTimestamp } from './api'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
FIXED_ANTE, FIXED_ANTE,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes, getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
@ -59,7 +61,7 @@ const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
const bodySchema = z.object({ const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH), 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(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
closeTime: zTimestamp().refine( closeTime: zTimestamp().refine(
(date) => date.getTime() > new Date().getTime(), (date) => date.getTime() > new Date().getTime(),
@ -133,41 +135,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
if (ante > user.balance) if (ante > user.balance)
throw new APIError(400, `Balance must be at least ${ante}.`) throw new APIError(400, `Balance must be at least ${ante}.`)
const slug = await getSlug(question) let group: Group | null = null
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
if (groupId) { if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId) const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get() 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.' '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)) { if (!group.contractIds.includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid) 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]), 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') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore

View File

@ -16,7 +16,7 @@ import {
cleanDisplayName, cleanDisplayName,
cleanUsername, cleanUsername,
} from '../../common/util/clean-username' } from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails' import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/envs/constants' import { isWhitelisted } from '../../common/envs/constants'
import { import {
CATEGORIES_GROUP_SLUG_POSTFIX, CATEGORIES_GROUP_SLUG_POSTFIX,
@ -96,6 +96,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
await addUserToDefaultGroups(user) await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser) await sendWelcomeEmail(user, privateUser)
await sendPersonalFollowupEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip }) await track(auth.uid, 'create user', { username }, { ip: req.ip })
return { user, privateUser } return { user, privateUser }

View File

@ -128,7 +128,20 @@
<div <div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" <p class="text-build-content"
style="line-height: 24px; 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 data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
using Manifold Markets. Running low using Manifold Markets. Running low
@ -161,6 +174,51 @@
</table> </table>
</td> </td>
</tr> </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;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr> <tr>
<td align="left" <td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"> style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

File diff suppressed because it is too large Load Diff

View File

@ -188,6 +188,56 @@
</table> </table>
</td> </td>
</tr> </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;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr> <tr>
<td align="left" <td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"> style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

View File

@ -1,3 +1,5 @@
import * as dayjs from 'dayjs'
import { DOMAIN } from '../../common/envs/constants' import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
@ -14,7 +16,7 @@ import {
import { getValueFromBucket } from '../../common/calculate-dpm' import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api' import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse' import { richTextToString } from '../../common/util/parse'
@ -74,9 +76,8 @@ export const sendMarketResolutionEmail = async (
// Modify template here: // Modify template here:
// https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial // 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, privateUser.email,
subject, subject,
'market-resolved', 'market-resolved',
@ -152,7 +153,7 @@ export const sendWelcomeEmail = async (
const emailType = 'generic' const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Welcome to Manifold Markets!', 'Welcome to Manifold Markets!',
'welcome', '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 ( export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
@ -183,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
const emailType = 'generic' const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold Markets one week anniversary gift', 'Manifold Markets one week anniversary gift',
'one-week', '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 ( export const sendThankYouEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
@ -215,7 +284,7 @@ export const sendThankYouEmail = async (
const emailType = 'generic' const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Thanks for your Manifold purchase', 'Thanks for your Manifold purchase',
'thank-you', 'thank-you',
@ -250,7 +319,7 @@ export const sendMarketCloseEmail = async (
const emailType = 'market-resolve' const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Your market has closed', 'Your market has closed',
'market-close', 'market-close',
@ -309,7 +378,7 @@ export const sendNewCommentEmail = async (
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}` const answerNumber = `#${answerId}`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
subject, subject,
'market-answer-comment', 'market-answer-comment',
@ -332,7 +401,7 @@ export const sendNewCommentEmail = async (
bet.outcome bet.outcome
)}` )}`
} }
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
subject, subject,
'market-comment', 'market-comment',
@ -377,7 +446,7 @@ export const sendNewAnswerEmail = async (
const subject = `New answer on ${question}` const subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>` const from = `${name} <info@manifold.markets>`
await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
subject, subject,
'market-answer', 'market-answer',

View File

@ -38,7 +38,7 @@ export * from './cancel-bet'
export * from './sell-bet' export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink' export * from './claim-manalink'
export * from './create-contract' export * from './create-market'
export * from './add-liquidity' export * from './add-liquidity'
export * from './withdraw-liquidity' export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
@ -57,7 +57,7 @@ import { cancelbet } from './cancel-bet'
import { sellbet } from './sell-bet' import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-contract' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { compact, uniq } from 'lodash' import { compact, uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils' import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
@ -24,7 +24,12 @@ export const onCreateCommentOnContract = functions
if (!contract) if (!contract)
throw new Error('Could not find contract corresponding with comment') 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 lastCommentTime = comment.createdTime
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
@ -59,7 +64,7 @@ export const onCreateCommentOnContract = functions
: undefined : undefined
} }
const comments = await getValues<Comment>( const comments = await getValues<ContractComment>(
firestore.collection('contracts').doc(contractId).collection('comments') firestore.collection('contracts').doc(contractId).collection('comments')
) )
const relatedSourceType = comment.replyToCommentId const relatedSourceType = comment.replyToCommentId

View File

@ -1,5 +1,5 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { Comment } from '../../common/comment' import { GroupComment } from '../../common/comment'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { User } from '../../common/user' import { User } from '../../common/user'
@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore
groupId: string groupId: string
} }
const comment = change.data() as Comment const comment = change.data() as GroupComment
const creatorSnapshot = await firestore const creatorSnapshot = await firestore
.collection('users') .collection('users')
.doc(comment.userId) .doc(comment.userId)

View File

@ -1,12 +1,17 @@
import * as functions from 'firebase-functions' 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 { createNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { User } from 'common/user'
import { sendCreatorGuideEmail } from './emails'
export const onCreateContract = functions.firestore export const onCreateContract = functions
.document('contracts/{contractId}') .runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}')
.onCreate(async (snapshot, context) => { .onCreate(async (snapshot, context) => {
const contract = snapshot.data() as Contract const contract = snapshot.data() as Contract
const { eventId } = context const { eventId } = context
@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore
richTextToString(desc), richTextToString(desc),
{ contract, recipients: mentioned } { contract, recipients: mentioned }
) )
await sendGuideEmail(contractCreator)
}) })
const firestore = admin.firestore()
const sendGuideEmail = async (contractCreator: User) => {
const query = await firestore
.collection(`contracts`)
.where('creatorId', '==', contractCreator.id)
.limit(2)
.get()
if (query.size >= 2) return
const privateUser = await getPrivateUser(contractCreator.id)
if (!privateUser) return
await sendCreatorGuideEmail(contractCreator, privateUser)
}

View File

@ -30,15 +30,7 @@ const bodySchema = z.object({
const binarySchema = z.object({ const binarySchema = z.object({
outcome: z.enum(['YES', 'NO']), outcome: z.enum(['YES', 'NO']),
limitProb: z limitProb: z.number().gte(0.001).lte(0.999).optional(),
.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(),
}) })
const freeResponseSchema = z.object({ const freeResponseSchema = z.object({
@ -89,7 +81,22 @@ export const placebet = newEndpoint({}, async (req, auth) => {
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
mechanism == 'cpmm-1' 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( const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc) getUnfilledBetsQuery(contractDoc)

View File

@ -0,0 +1,31 @@
// Comment types were introduced in August 2022.
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
if (require.main === module) {
const app = initAdmin()
const firestore = app.firestore()
const commentsRef = firestore.collectionGroup('comments')
commentsRef.get().then(async (commentsSnaps) => {
log(`Loaded ${commentsSnaps.size} comments.`)
const needsFilling = commentsSnaps.docs.filter((ct) => {
return !('commentType' in ct.data())
})
log(`Found ${needsFilling.length} comments to update.`)
const updates = needsFilling.map((d) => {
const comment = d.data()
const fields: { [k: string]: unknown } = {}
if (comment.contractId != null && comment.groupId == null) {
fields.commentType = 'contract'
} else if (comment.groupId != null && comment.contractId == null) {
fields.commentType = 'group'
} else {
log(`Invalid comment ${comment}; not touching it.`)
}
return { doc: d.ref, fields, info: comment }
})
await writeAsync(firestore, updates)
log(`Updated all comments.`)
})
}

View File

@ -0,0 +1,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))
}

View File

@ -1,27 +1,35 @@
import * as mailgun from 'mailgun-js' import * as mailgun from 'mailgun-js'
import { tryOrLogError } from './utils'
const initMailgun = () => { const initMailgun = () => {
const apiKey = process.env.MAILGUN_KEY as string const apiKey = process.env.MAILGUN_KEY as string
return mailgun({ apiKey, domain: 'mg.manifold.markets' }) 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 = { const data: mailgun.messages.SendData = {
from: 'Manifold Markets <info@manifold.markets>', ...options,
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
to, to,
subject, subject,
text, text,
// Don't rewrite urls in plaintext emails // Don't rewrite urls in plaintext emails
'o:tracking-clicks': 'htmlonly', 'o:tracking-clicks': 'htmlonly',
} }
const mg = initMailgun() const mg = initMailgun().messages()
return mg.messages().send(data, (error) => { const result = await tryOrLogError(mg.send(data))
if (error) console.log('Error sending email', error) if (result != null) {
else console.log('Sent text email', to, subject) console.log('Sent text email', to, subject)
}) }
return result
} }
export const sendTemplateEmail = ( export const sendTemplateEmail = async (
to: string, to: string,
subject: string, subject: string,
templateId: string, templateId: string,
@ -38,10 +46,10 @@ export const sendTemplateEmail = (
'o:tag': templateId, 'o:tag': templateId,
'o:tracking': true, 'o:tracking': true,
} }
const mg = initMailgun() const mg = initMailgun().messages()
const result = await tryOrLogError(mg.send(data))
return mg.messages().send(data, (error) => { if (result != null) {
if (error) console.log('Error sending email', error) console.log('Sent template email', templateId, to, subject)
else console.log('Sent template email', templateId, to, subject) }
}) return result
} }

View File

@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet'
import { sellbet } from './sell-bet' import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-contract' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'

View File

@ -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 = () => { export const isProd = () => {
return admin.instanceId().app.options.projectId === 'mantic-markets' return admin.instanceId().app.options.projectId === 'mantic-markets'
} }

View File

@ -5,6 +5,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended', 'plugin:react-hooks/recommended',
'plugin:@next/next/recommended', 'plugin:@next/next/recommended',
'prettier',
], ],
rules: { rules: {
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',

View File

@ -1,6 +1,6 @@
import Router from 'next/router' import Router from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { MouseEvent, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
export function Avatar(props: { export function Avatar(props: {
@ -12,6 +12,7 @@ export function Avatar(props: {
}) { }) {
const { username, noLink, size, className } = props const { username, noLink, size, className } = props
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const onClick = const onClick =

View File

@ -448,8 +448,6 @@ function LimitOrderPanel(props: {
const yesAmount = shares * (yesLimitProb ?? 1) const yesAmount = shares * (yesLimitProb ?? 1)
const noAmount = shares * (1 - (noLimitProb ?? 0)) const noAmount = shares * (1 - (noLimitProb ?? 0))
const profitIfBothFilled = shares - (yesAmount + noAmount)
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false) setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
@ -559,6 +557,8 @@ function LimitOrderPanel(props: {
) )
const noReturnPercent = formatPercent(noReturn) const noReturnPercent = formatPercent(noReturn)
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 items-center gap-4">

View File

@ -11,7 +11,7 @@ import { User } from 'common/user'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Button } from '../button' import { Button } from '../button'
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { BinaryContract } from 'common/contract' import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label' import { NoLabel, YesLabel } from '../outcome-label'
@ -19,24 +19,32 @@ import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input' import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/antes'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
expiresTime: number | null expiresTime: number | null
message: string
outcome: 'YES' | 'NO' | number outcome: 'YES' | 'NO' | number
acceptorAmount: number acceptorAmount: number
question: string
} }
export function CreateChallengeModal(props: { export function CreateChallengeModal(props: {
user: User | null | undefined user: User | null | undefined
contract: BinaryContract
isOpen: boolean isOpen: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
contract?: BinaryContract
}) { }) {
const { user, contract, isOpen, setOpen } = props const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('') const [challengeSlug, setChallengeSlug] = useState('')
const [loading, setLoading] = useState(false)
const { editor } = useTextEditor({ placeholder: '' })
return ( return (
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
@ -46,24 +54,42 @@ export function CreateChallengeModal(props: {
<CreateChallengeForm <CreateChallengeForm
user={user} user={user}
contract={contract} contract={contract}
loading={loading}
onCreate={async (newChallenge) => { onCreate={async (newChallenge) => {
const challenge = await createChallenge({ setLoading(true)
creator: user, try {
creatorAmount: newChallenge.amount, const challengeContract = contract
expiresTime: newChallenge.expiresTime, ? contract
message: newChallenge.message, : await createMarket(
acceptorAmount: newChallenge.acceptorAmount, removeUndefinedProps({
outcome: newChallenge.outcome, question: newChallenge.question,
contract: contract, outcomeType: 'BINARY',
}) initialProb: 50,
if (challenge) { description: editor?.getJSON(),
setChallengeSlug(getChallengeUrl(challenge)) ante: FIXED_ANTE,
track('challenge created', { closeTime: dayjs().add(30, 'day').valueOf(),
creator: user.username, })
amount: newChallenge.amount, )
contractId: contract.id, const challenge = await createChallenge({
creator: user,
creatorAmount: newChallenge.amount,
expiresTime: newChallenge.expiresTime,
acceptorAmount: newChallenge.acceptorAmount,
outcome: newChallenge.outcome,
contract: challengeContract as BinaryContract,
}) })
if (challenge) {
setChallengeSlug(getChallengeUrl(challenge))
track('challenge created', {
creator: user.username,
amount: newChallenge.amount,
contractId: challengeContract.id,
})
}
} catch (e) {
console.error("couldn't create market/challenge:", e)
} }
setLoading(false)
}} }}
challengeSlug={challengeSlug} challengeSlug={challengeSlug}
/> />
@ -75,25 +101,24 @@ export function CreateChallengeModal(props: {
function CreateChallengeForm(props: { function CreateChallengeForm(props: {
user: User user: User
contract: BinaryContract
onCreate: (m: challengeInfo) => Promise<void> onCreate: (m: challengeInfo) => Promise<void>
challengeSlug: string challengeSlug: string
loading: boolean
contract?: BinaryContract
}) { }) {
const { user, onCreate, contract, challengeSlug } = props const { user, onCreate, contract, challengeSlug, loading } = props
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week' const defaultExpire = 'week'
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(), expiresTime: dayjs().add(2, defaultExpire).valueOf(),
outcome: 'YES', outcome: 'YES',
amount: 100, amount: 100,
acceptorAmount: 100, acceptorAmount: 100,
message: defaultMessage, question: contract ? contract.question : '',
}) })
useEffect(() => { useEffect(() => {
setError('') setError('')
@ -106,7 +131,15 @@ function CreateChallengeForm(props: {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
if (user.balance < challengeInfo.amount) { if (user.balance < challengeInfo.amount) {
setError('You do not have enough mana to create this challenge') setError("You don't have enough mana to create this challenge")
return
}
if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) {
setError(
`You don't have enough mana to create this challenge and market. You need ${formatMoney(
FIXED_ANTE + challengeInfo.amount
)}`
)
return return
} }
setIsCreating(true) setIsCreating(true)
@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
<div className="mb-8"> <div className="mb-8">
Challenge a friend to bet on{' '} Challenge a friend to bet on{' '}
<span className="underline">{contract.question}</span> {contract ? (
<span className="underline">{contract.question}</span>
) : (
<Textarea
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
onChange={(e) =>
setChallengeInfo({
...challengeInfo,
question: e.target.value,
})
}
/>
)}
</div> </div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
@ -187,22 +236,23 @@ function CreateChallengeForm(props: {
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row> </Row>
</div> </div>
<Button {contract && (
size="2xs" <Button
color="gray" size="2xs"
onClick={() => { color="gray"
setEditingAcceptorAmount(true) onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
)}
<div className="mt-8"> <div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '} If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold"> <span className="font-semibold">
@ -210,7 +260,18 @@ function CreateChallengeForm(props: {
challengeInfo.acceptorAmount + challengeInfo.amount || 0 challengeInfo.acceptorAmount + challengeInfo.amount || 0
)} )}
</span>{' '} </span>{' '}
in total. in total.{' '}
<span>
{!contract && (
<span>
Because there's no market yet, you'll be charged
<span className={'mx-1 font-semibold'}>
{formatMoney(FIXED_ANTE)}
</span>
to create it.
</span>
)}
</span>
</div> </div>
<Row className="mt-8 items-center"> <Row className="mt-8 items-center">
@ -218,10 +279,8 @@ function CreateChallengeForm(props: {
type="submit" type="submit"
color={'gradient'} color={'gradient'}
size="xl" size="xl"
className={clsx( disabled={isCreating || challengeInfo.question === ''}
'whitespace-nowrap drop-shadow-md', className={clsx('whitespace-nowrap drop-shadow-md')}
isCreating ? 'disabled' : ''
)}
> >
Create challenge bet Create challenge bet
</Button> </Button>
@ -229,7 +288,12 @@ function CreateChallengeForm(props: {
<Row className={'text-error'}>{error} </Row> <Row className={'text-error'}>{error} </Row>
</form> </form>
)} )}
{finishedCreating && ( {loading && (
<Col className={'h-56 w-full items-center justify-center'}>
<LoadingIndicator />
</Col>
)}
{finishedCreating && !loading && (
<> <>
<Title className="!my-0" text="Challenge Created!" /> <Title className="!my-0" text="Challenge Created!" />

View File

@ -1,12 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Dictionary, keyBy, uniq } from 'lodash'
import { Comment } from 'common/comment' import { Comment, ContractComment } from 'common/comment'
import { Contract } from 'common/contract' import { groupConsecutive } from 'common/util/array'
import { filterDefined, groupConsecutive } from 'common/util/array'
import { contractPath } from 'web/lib/firebase/contracts'
import { getUsersComments } from 'web/lib/firebase/comments' import { getUsersComments } from 'web/lib/firebase/comments'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Avatar } from './avatar' import { Avatar } from './avatar'
@ -20,12 +16,15 @@ import { LoadingIndicator } from './loading-indicator'
const COMMENTS_PER_PAGE = 50 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 }) { export function UserCommentsList(props: { user: User }) {
const { user } = props const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>() const [comments, setComments] = useState<ContractComment[] | undefined>()
const [contracts, setContracts] = useState<Dictionary<Contract> | undefined>()
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE const end = start + COMMENTS_PER_PAGE
@ -33,38 +32,29 @@ export function UserCommentsList(props: { user: User }) {
useEffect(() => { useEffect(() => {
getUsersComments(user.id).then((cs) => { getUsersComments(user.id).then((cs) => {
// we don't show comments in groups here atm, just comments on contracts // we don't show comments in groups here atm, just comments on contracts
setComments(cs.filter((c) => c.contractId) as ContractComment[]) setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
}) })
}, [user.id]) }, [user.id])
useEffect(() => { if (comments == null) {
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) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
const pageComments = groupConsecutive( const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
comments.slice(start, end), return { question: c.contractQuestion, slug: c.contractSlug }
(c) => c.contractId })
)
return ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => { {pageComments.map(({ key, items }, i) => {
const contract = contracts[key]
return ( return (
<div key={start + i} className="border-b p-5"> <div key={start + i} className="border-b p-5">
<SiteLink <SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700" className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(contract)} href={contractPath(key.slug)}
> >
{contract.question} {key.question}
</SiteLink> </SiteLink>
<Col className="gap-6"> <Col className="gap-6">
{items.map((comment) => ( {items.map((comment) => (

View File

@ -83,7 +83,7 @@ export function ContractSearch(props: {
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
gridClassName?: string overrideGridClassName?: string
cardHideOptions?: { cardHideOptions?: {
hideGroupLink?: boolean hideGroupLink?: boolean
hideQuickBet?: boolean hideQuickBet?: boolean
@ -91,6 +91,7 @@ export function ContractSearch(props: {
headerClassName?: string headerClassName?: string
useQuerySortLocalStorage?: boolean useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean useQuerySortUrlParams?: boolean
isWholePage?: boolean
}) { }) {
const { const {
user, user,
@ -98,13 +99,14 @@ export function ContractSearch(props: {
defaultFilter, defaultFilter,
additionalFilter, additionalFilter,
onContractClick, onContractClick,
gridClassName, overrideGridClassName,
hideOrderSelector, hideOrderSelector,
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
headerClassName, headerClassName,
useQuerySortLocalStorage, useQuerySortLocalStorage,
useQuerySortUrlParams, useQuerySortUrlParams,
isWholePage,
} = props } = props
const [numPages, setNumPages] = useState(1) const [numPages, setNumPages] = useState(1)
@ -139,7 +141,7 @@ export function ContractSearch(props: {
setNumPages(results.nbPages) setNumPages(results.nbPages)
if (freshQuery) { if (freshQuery) {
setPages([newPage]) setPages([newPage])
window.scrollTo(0, 0) if (isWholePage) window.scrollTo(0, 0)
} else { } else {
setPages((pages) => [...pages, newPage]) setPages((pages) => [...pages, newPage])
} }
@ -181,7 +183,7 @@ export function ContractSearch(props: {
loadMore={performQuery} loadMore={performQuery}
showTime={showTime} showTime={showTime}
onContractClick={onContractClick} onContractClick={onContractClick}
gridClassName={gridClassName} overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions} cardHideOptions={cardHideOptions}
/> />

View File

@ -122,19 +122,6 @@ export function ContractCard(props: {
) : ( ) : (
<FreeResponseTopAnswer contract={contract} truncate="long" /> <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> </Col>
{showQuickBet ? ( {showQuickBet ? (
<QuickBet contract={contract} user={user} /> <QuickBet contract={contract} user={user} />
@ -172,6 +159,24 @@ export function ContractCard(props: {
<ProbBar contract={contract} /> <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> </Row>
) )
} }

View File

@ -58,13 +58,13 @@ export function MiscDetails(props: {
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
return ( 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 ? ( {showHotVolume ? (
<Row className="gap-0.5"> <Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row> </Row>
) : showTime === 'close-date' ? ( ) : showTime === 'close-date' ? (
<Row className="gap-0.5"> <Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)} {fromNow(closeTime || 0)}

View File

@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<tr> <tr>
<td>Payout</td> <td>Payout</td>
<td> <td className="flex gap-1">
{mechanism === 'cpmm-1' ? ( {mechanism === 'cpmm-1' ? (
<> <>
Fixed{' '} Fixed{' '}
<InfoTooltip text="Each YES share is worth M$1 if YES wins." /> <InfoTooltip text="Each YES share is worth M$1 if YES wins." />
</> </>
) : ( ) : (
<div> <>
Parimutuel{' '} Parimutuel{' '}
<InfoTooltip text="Each share is a fraction of the pool. " /> <InfoTooltip text="Each share is a fraction of the pool. " />
</div> </>
)} )}
</td> </td>
</tr> </tr>

View File

@ -1,5 +1,5 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { ContractComment } from 'common/comment'
import { resolvedPayout } from 'common/calculate' import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -65,7 +65,7 @@ export function ContractLeaderboard(props: {
export function ContractTopTrades(props: { export function ContractTopTrades(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, bets, comments, tips } = props const { contract, bets, comments, tips } = props

View File

@ -1,6 +1,6 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Comment } from 'web/lib/firebase/comments' import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractActivity } from '../feed/contract-activity' import { ContractActivity } from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable, BetsSummary } from '../bets-list'
@ -15,7 +15,7 @@ export function ContractTabs(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
bets: Bet[] bets: Bet[]
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, bets, tips } = props const { contract, user, bets, tips } = props

View File

@ -20,7 +20,7 @@ export function ContractsGrid(props: {
loadMore?: () => void loadMore?: () => void
showTime?: ShowTime showTime?: ShowTime
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
gridClassName?: string overrideGridClassName?: string
cardHideOptions?: { cardHideOptions?: {
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
@ -32,7 +32,7 @@ export function ContractsGrid(props: {
showTime, showTime,
loadMore, loadMore,
onContractClick, onContractClick,
gridClassName, overrideGridClassName,
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
} = props } = props
@ -66,8 +66,9 @@ export function ContractsGrid(props: {
<Col className="gap-8"> <Col className="gap-8">
<ul <ul
className={clsx( className={clsx(
'w-full columns-1 gap-4 space-y-4 md:columns-2', overrideGridClassName
gridClassName ? overrideGridClassName
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
)} )}
> >
{contracts.map((contract) => ( {contracts.map((contract) => (
@ -80,10 +81,11 @@ export function ContractsGrid(props: {
} }
hideQuickBet={hideQuickBet} hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
className={clsx( className={
'break-inside-avoid-column', contractIds?.includes(contract.id)
contractIds?.includes(contract.id) && highlightClassName ? highlightClassName
)} : undefined
}
/> />
))} ))}
</ul> </ul>

View File

@ -65,7 +65,9 @@ export function MarketModal(props: {
<ContractSearch <ContractSearch
hideOrderSelector hideOrderSelector
onContractClick={addContract} 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 }} cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), contractIds: contracts.map((c) => c.id),

View File

@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900' selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
)} )}
onClick={() => submitUser(i)} onClick={() => submitUser(i)}
key={user.id}
> >
<Avatar avatarUrl={user.avatarUrl} size="xs" /> <Avatar avatarUrl={user.avatarUrl} size="xs" />
{user.username} {user.username}

View File

@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Comment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
@ -28,7 +28,7 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & { export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[] commentsByCurrentUser: ContractComment[]
} }
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & {
export type CommentThreadItem = BaseActivityItem & { export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread' type: 'commentThread'
parentComment: Comment parentComment: ContractComment
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
bets: Bet[] bets: Bet[]
} }
@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' type: 'answergroup'
user: User | undefined | null user: User | undefined | null
answer: Answer answer: Answer
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
bets: Bet[] bets: Bet[]
} }
@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & {
function getAnswerAndCommentInputGroups( function getAnswerAndCommentInputGroups(
contract: FreeResponseContract, contract: FreeResponseContract,
bets: Bet[], bets: Bet[],
comments: Comment[], comments: ContractComment[],
tips: CommentTipMap, tips: CommentTipMap,
user: User | undefined | null user: User | undefined | null
) { ) {
@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups(
function getCommentThreads( function getCommentThreads(
bets: Bet[], bets: Bet[],
comments: Comment[], comments: ContractComment[],
tips: CommentTipMap, tips: CommentTipMap,
contract: Contract contract: Contract
) { ) {
@ -135,7 +135,7 @@ function getCommentThreads(
return items return items
} }
function commentIsGeneralComment(comment: Comment, contract: Contract) { function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
return ( return (
comment.answerOutcome === undefined && comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE' (contract.outcomeType === 'FREE_RESPONSE'
@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) {
export function getSpecificContractActivityItems( export function getSpecificContractActivityItems(
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
comments: Comment[], comments: ContractComment[],
liquidityProvisions: LiquidityProvision[], liquidityProvisions: LiquidityProvision[],
tips: CommentTipMap, tips: CommentTipMap,
user: User | null | undefined, user: User | null | undefined,

View File

@ -1,167 +0,0 @@
import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { union, difference } from 'lodash'
import { Row } from '../layout/row'
import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories'
import { Modal } from '../layout/modal'
import { Col } from '../layout/col'
import { useState } from 'react'
import { updateUser, User } from 'web/lib/firebase/users'
import { Checkbox } from '../checkbox'
import { track } from 'web/lib/service/analytics'
export function CategorySelector(props: {
category: string
setCategory: (category: string) => void
className?: string
}) {
const { className, category, setCategory } = props
return (
<Row
className={clsx(
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
className
)}
>
<div />
<CategoryButton
key="all"
category="All"
isFollowed={category === 'all'}
toggle={() => {
setCategory('all')
}}
/>
<CategoryButton
key="following"
category="Following"
isFollowed={category === 'following'}
toggle={() => {
setCategory('following')
}}
/>
{CATEGORY_LIST.map((cat) => (
<CategoryButton
key={cat}
category={CATEGORIES[cat as category].split(' ')[0]}
isFollowed={cat === category}
toggle={() => {
setCategory(cat)
}}
/>
))}
</Row>
)
}
function CategoryButton(props: {
category: string
isFollowed: boolean
toggle: () => void
className?: string
}) {
const { toggle, category, isFollowed, className } = props
return (
<div
className={clsx(
className,
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
'cursor-pointer select-none',
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
)}
onClick={toggle}
>
<span className="text-sm text-gray-500">{category}</span>
</div>
)
}
export function EditCategoriesButton(props: {
user: User
className?: string
}) {
const { user, className } = props
const [isOpen, setIsOpen] = useState(false)
return (
<div
className={clsx(
className,
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
)}
onClick={() => {
setIsOpen(true)
track('edit categories button')
}}
>
<PencilIcon className="inline h-4 w-4" />
Categories
<CategorySelectorModal
user={user}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</div>
)
}
function CategorySelectorModal(props: {
user: User
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}) {
const { user, isOpen, setIsOpen } = props
const followedCategories =
user?.followedCategories === undefined
? CATEGORY_LIST
: user.followedCategories
const selectAll =
user.followedCategories === undefined ||
followedCategories.length < CATEGORY_LIST.length
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<button
className="btn btn-sm btn-outline mb-4 self-start normal-case"
onClick={() => {
if (selectAll) {
updateUser(user.id, {
followedCategories: CATEGORY_LIST,
})
} else {
updateUser(user.id, {
followedCategories: [],
})
}
}}
>
Select {selectAll ? 'all' : 'none'}
</button>
<Col className="grid w-full grid-cols-2 gap-4">
{CATEGORY_LIST.map((cat) => (
<Checkbox
className="col-span-1"
key={cat}
label={CATEGORIES[cat as category].split(' ')[0]}
checked={followedCategories.includes(cat)}
toggle={(checked) => {
updateUser(user.id, {
followedCategories: checked
? difference(followedCategories, [cat])
: union([cat], followedCategories),
})
}}
/>
))}
</Col>
</Col>
</Modal>
)
}

View File

@ -1,5 +1,5 @@
import { Contract } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments' import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets' import { useBets } from 'web/hooks/use-bets'
import { getSpecificContractActivityItems } from './activity-items' import { getSpecificContractActivityItems } from './activity-items'
@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision'
export function ContractActivity(props: { export function ContractActivity(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
comments: Comment[] comments: ContractComment[]
liquidityProvisions: LiquidityProvision[] liquidityProvisions: LiquidityProvision[]
tips: CommentTipMap tips: CommentTipMap
user: User | null | undefined user: User | null | undefined

View File

@ -1,6 +1,6 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: {
contract: any contract: any
user: User | undefined | null user: User | undefined | null
answer: Answer answer: Answer
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
bets: Bet[] bets: Bet[]
}) { }) {
@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: {
]) ])
const scrollAndOpenReplyInput = useEvent( const scrollAndOpenReplyInput = useEvent(
(comment?: Comment, answer?: Answer) => { (comment?: ContractComment, answer?: Answer) => {
setReplyToUser( setReplyToUser(
comment comment
? { id: comment.userId, username: comment.userUsername } ? { id: comment.userId, username: comment.userUsername }

View File

@ -1,5 +1,5 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
contract: Contract contract: Contract
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
parentComment: Comment parentComment: ContractComment
bets: Bet[] bets: Bet[]
smallAvatar?: boolean smallAvatar?: boolean
}) { }) {
@ -50,7 +50,7 @@ export function FeedCommentThread(props: {
) )
commentsList.unshift(parentComment) commentsList.unshift(parentComment)
function scrollAndOpenReplyInput(comment: Comment) { function scrollAndOpenReplyInput(comment: ContractComment) {
setReplyToUser({ id: comment.userId, username: comment.userUsername }) setReplyToUser({ id: comment.userId, username: comment.userUsername })
setShowReply(true) setShowReply(true)
} }
@ -95,10 +95,10 @@ export function FeedCommentThread(props: {
export function CommentRepliesList(props: { export function CommentRepliesList(props: {
contract: Contract contract: Contract
commentsList: Comment[] commentsList: ContractComment[]
betsByUserId: Dictionary<Bet[]> betsByUserId: Dictionary<Bet[]>
tips: CommentTipMap tips: CommentTipMap
scrollAndOpenReplyInput: (comment: Comment) => void scrollAndOpenReplyInput: (comment: ContractComment) => void
bets: Bet[] bets: Bet[]
treatFirstIndexEqually?: boolean treatFirstIndexEqually?: boolean
smallAvatar?: boolean smallAvatar?: boolean
@ -156,12 +156,12 @@ export function CommentRepliesList(props: {
export function FeedComment(props: { export function FeedComment(props: {
contract: Contract contract: Contract
comment: Comment comment: ContractComment
tips: CommentTips tips: CommentTips
betsBySameUser: Bet[] betsBySameUser: Bet[]
probAtCreatedTime?: number probAtCreatedTime?: number
smallAvatar?: boolean smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { const {
contract, contract,
@ -274,7 +274,7 @@ export function FeedComment(props: {
export function getMostRecentCommentableBet( export function getMostRecentCommentableBet(
betsByCurrentUser: Bet[], betsByCurrentUser: Bet[],
commentsByCurrentUser: Comment[], commentsByCurrentUser: ContractComment[],
user?: User | null, user?: User | null,
answerOutcome?: string answerOutcome?: string
) { ) {
@ -319,7 +319,7 @@ function CommentStatus(props: {
export function CommentInput(props: { export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[] commentsByCurrentUser: ContractComment[]
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
// Reply to a free response answer // Reply to a free response answer
parentAnswerOutcome?: string parentAnswerOutcome?: string

View File

@ -1,6 +1,6 @@
import { groupBy, mapValues, maxBy, sortBy } from 'lodash' import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
import { Contract } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments' import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
const MAX_ACTIVE_CONTRACTS = 75 const MAX_ACTIVE_CONTRACTS = 75
@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) {
// - Bet on market // - Bet on market
export function findActiveContracts( export function findActiveContracts(
allContracts: Contract[], allContracts: Contract[],
recentComments: Comment[], recentComments: ContractComment[],
recentBets: Bet[], recentBets: Bet[],
seenContracts: { [contractId: string]: number } seenContracts: { [contractId: string]: number }
) { ) {
@ -73,7 +73,7 @@ export function findActiveContracts(
) )
const contractMostRecentComment = mapValues( const contractMostRecentComment = mapValues(
contractComments, contractComments,
(comments) => maxBy(comments, (c) => c.createdTime) as Comment (comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
) )
const prioritizedContracts = sortBy(activeContracts, (c) => { const prioritizedContracts = sortBy(activeContracts, (c) => {

View File

@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user'
import React, { useEffect, memo, useState, useMemo } from 'react' import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group' import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' import { Comment, GroupComment } from 'common/comment'
import { createCommentOnGroup } from 'web/lib/firebase/comments'
import { CommentInputTextArea } from 'web/components/feed/feed-comments' import { CommentInputTextArea } from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user' import { usePrivateUser } from 'web/hooks/use-user'
export function GroupChat(props: { export function GroupChat(props: {
messages: Comment[] messages: GroupComment[]
user: User | null | undefined user: User | null | undefined
group: Group group: Group
tips: CommentTipMap tips: CommentTipMap
@ -58,7 +59,7 @@ export function GroupChat(props: {
// array of groups, where each group is an array of messages that are displayed as one // array of groups, where each group is an array of messages that are displayed as one
const groupedMessages = useMemo(() => { const groupedMessages = useMemo(() => {
// Group messages with createdTime within 2 minutes of each other. // Group messages with createdTime within 2 minutes of each other.
const tempGrouped: Comment[][] = [] const tempGrouped: GroupComment[][] = []
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const message = messages[i] const message = messages[i]
if (i === 0) tempGrouped.push([message]) if (i === 0) tempGrouped.push([message])
@ -193,7 +194,7 @@ export function GroupChat(props: {
} }
export function GroupChatInBubble(props: { export function GroupChatInBubble(props: {
messages: Comment[] messages: GroupComment[]
user: User | null | undefined user: User | null | undefined
privateUser: PrivateUser | null | undefined privateUser: PrivateUser | null | undefined
group: Group group: Group
@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: {
const GroupMessage = memo(function GroupMessage_(props: { const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined user: User | null | undefined
comments: Comment[] comments: GroupComment[]
group: Group group: Group
onReplyClick?: (comment: Comment) => void onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void setRef?: (ref: HTMLDivElement) => void
@ -353,7 +354,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
elementId={id} elementId={id}
/> />
</Row> </Row>
<div className="mt-2 text-black"> <div className="mt-2 text-base text-black">
{comments.map((comment) => ( {comments.map((comment) => (
<Content <Content
key={comment.id} key={comment.id}

View File

@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
import { NoLabel, YesLabel } from './outcome-label' import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col' import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip'
export function LiquidityPanel(props: { contract: CPMMContract }) { export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props const { contract } = props
@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
return ( return (
<> <>
<div className="align-center mb-4 text-gray-500"> <div className="mb-4 text-gray-500">
Subsidize this market by adding M$ to the liquidity pool. Contribute your M$ to make this market more accurate.{' '}
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
</div> </div>
<Row> <Row>

View File

@ -33,7 +33,7 @@ function getNavigation() {
const signedOutNavigation = [ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon }, { name: 'Explore', href: '/home', icon: SearchIcon },
] ]
// From https://codepen.io/chris__sev/pen/QWGvYbL // From https://codepen.io/chris__sev/pen/QWGvYbL

View File

@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) {
} }
const signedOutNavigation = [ const signedOutNavigation = [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon }, { name: 'Explore', href: '/home', icon: SearchIcon },
{ {
name: 'About', name: 'About',
href: 'https://docs.manifold.markets/$how-to', href: 'https://docs.manifold.markets/$how-to',

View File

@ -1,55 +0,0 @@
import clsx from 'clsx'
import { useState } from 'react'
import { parseWordsAsTags } from 'common/util/parse'
import { Contract, updateContract } from 'web/lib/firebase/contracts'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
import { MAX_TAG_LENGTH } from 'common/contract'
export function TagsInput(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const { tags } = contract
const [tagText, setTagText] = useState('')
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
const [isSubmitting, setIsSubmitting] = useState(false)
const updateTags = async () => {
setIsSubmitting(true)
await updateContract(contract.id, {
tags: newTags,
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
})
setIsSubmitting(false)
setTagText('')
}
return (
<Col className={clsx('gap-4', className)}>
<TagsList tags={newTags} noLabel />
<Row className="items-center gap-4">
<input
style={{ maxWidth: 150 }}
placeholder="Type a tag..."
className="input input-sm input-bordered resize-none"
disabled={isSubmitting}
value={tagText}
maxLength={MAX_TAG_LENGTH}
onChange={(e) => setTagText(e.target.value || '')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
updateTags()
}
}}
/>
<button className="btn btn-xs btn-outline" onClick={updateTags}>
Save tags
</button>
</Row>
</Col>
)
}

View File

@ -1,86 +0,0 @@
import clsx from 'clsx'
import { CATEGORIES, category } from '../../common/categories'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { SiteLink } from './site-link'
function Hashtag(props: { tag: string; noLink?: boolean }) {
const { tag, noLink } = props
const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category]
const body = (
<div className={clsx('', !noLink && 'cursor-pointer')}>
<span className="text-sm">{category ? '#' + category : tag} </span>
</div>
)
if (noLink) return body
return (
<SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center">
{body}
</SiteLink>
)
}
export function TagsList(props: {
tags: string[]
className?: string
noLink?: boolean
noLabel?: boolean
label?: string
}) {
const { tags, className, noLink, noLabel, label } = props
return (
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
{tags.map((tag) => (
<Hashtag
key={tag}
tag={tag.startsWith('#') ? tag : `#${tag}`}
noLink={noLink}
/>
))}
</Row>
)
}
export function FoldTag(props: { fold: { slug: string; name: string } }) {
const { fold } = props
const { slug, name } = fold
return (
<SiteLink href={`/fold/${slug}`} className="flex items-center">
<div
className={clsx(
'rounded-full border-2 bg-white px-4 py-1 shadow-md',
'cursor-pointer'
)}
>
<span className="text-sm text-gray-500">{name}</span>
</div>
</SiteLink>
)
}
export function FoldTagList(props: {
folds: { slug: string; name: string }[]
noLabel?: boolean
className?: string
}) {
const { folds, noLabel, className } = props
return (
<Col className="gap-2">
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{folds.length > 0 && (
<>
{folds.map((fold) => (
<FoldTag key={fold.slug} fold={fold} />
))}
</>
)}
</Row>
</Col>
)
}

View File

@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
return return
} }
const contractId =
comment.commentType === 'contract' ? comment.contractId : undefined
const groupId =
comment.commentType === 'group' ? comment.groupId : undefined
await transact({ await transact({
amount: change, amount: change,
fromId: user.id, fromId: user.id,
@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER', toType: 'USER',
token: 'M$', token: 'M$',
category: 'TIP', category: 'TIP',
data: { data: { commentId: comment.id, contractId, groupId },
contractId: comment.contractId,
commentId: comment.id,
groupId: comment.groupId,
},
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
}) })
track('send comment tip', { track('send comment tip', {
contractId: comment.contractId,
commentId: comment.id, commentId: comment.id,
groupId: comment.groupId, contractId,
groupId,
amount: change, amount: change,
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,

View File

@ -6,7 +6,6 @@ import {
Placement, Placement,
shift, shift,
useFloating, useFloating,
useFocus,
useHover, useHover,
useInteractions, useInteractions,
useRole, useRole,
@ -48,7 +47,6 @@ export function Tooltip(props: {
const { getReferenceProps, getFloatingProps } = useInteractions([ const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context, { mouseOnly: noTap }), useHover(context, { mouseOnly: noTap }),
useFocus(context),
useRole(context, { role: 'tooltip' }), useRole(context, { role: 'tooltip' }),
]) ])
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip // which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
@ -64,7 +62,6 @@ export function Tooltip(props: {
<div <div
className={clsx('inline-block', className)} className={clsx('inline-block', className)}
ref={reference} ref={reference}
tabIndex={noTap ? undefined : 0}
{...getReferenceProps()} {...getReferenceProps()}
> >
{children} {children}

View File

@ -58,8 +58,6 @@ export function UserLink(props: {
) )
} }
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
export function UserPage(props: { user: User }) { export function UserPage(props: { user: User }) {
const { user } = props const { user } = props
const router = useRouter() const router = useRouter()
@ -103,10 +101,10 @@ export function UserPage(props: { user: User }) {
</div> </div>
{/* Top right buttons (e.g. edit, follow) */} {/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4"> <div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />} {!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && ( {isCurrentUser && (
<SiteLink className="btn" href="/profile"> <SiteLink className="sm:btn-md btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '} <PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div> <div className="ml-2">Edit</div>
</SiteLink> </SiteLink>
@ -116,19 +114,22 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */} {/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6"> <Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span> <Row className={'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">@{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} /> <Spacer h={4} />
{user.bio && ( {user.bio && (
<> <>
@ -138,17 +139,7 @@ export function UserPage(props: { user: User }) {
<Spacer h={4} /> <Spacer h={4} />
</> </>
)} )}
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4"> <Row className="flex-wrap items-center gap-2 sm:gap-4">
<Row className="gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} />
</Row>
{user.website && ( {user.website && (
<SiteLink <SiteLink
href={ href={
@ -198,7 +189,7 @@ export function UserPage(props: { user: User }) {
</Row> </Row>
</SiteLink> </SiteLink>
)} )}
</Col> </Row>
<Spacer h={5} /> <Spacer h={5} />
{currentUser?.id === user.id && ( {currentUser?.id === user.id && (
<Row <Row
@ -208,7 +199,7 @@ export function UserPage(props: { user: User }) {
> >
<span> <span>
<SiteLink href="/referrals"> <SiteLink href="/referrals">
Refer a friend and earn {formatMoney(500)} when they sign up! Earn {formatMoney(500)} when you refer a friend!
</SiteLink>{' '} </SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} /> You have <ReferralsButton user={user} currentUser={currentUser} />
</span> </span>
@ -244,6 +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> </Col>

View File

@ -1,51 +0,0 @@
import { useState, useEffect } from 'react'
import type { feed } from 'common/feed'
import { useTimeSinceFirstRender } from './use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { User } from 'common/user'
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
import {
getRecentBetsAndComments,
getTopWeeklyContracts,
} from 'web/lib/firebase/contracts'
export const useAlgoFeed = (
user: User | null | undefined,
category: string
) => {
const [allFeed, setAllFeed] = useState<feed>()
const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>()
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (user) {
getUserFeed(user.id).then((feed) => {
if (feed.length === 0) {
getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed)
trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime())
})
getCategoryFeeds(user.id).then((feeds) => {
setCategoryFeeds(feeds)
console.log('category feeds load time', getTime())
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id])
const feed = category === 'all' ? allFeed : categoryFeeds?.[category]
return feed
}
const getDefaultFeed = async () => {
const contracts = await getTopWeeklyContracts()
const feed = await Promise.all(
contracts.map((c) => getRecentBetsAndComments(c))
)
return feed
}

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Comment, ContractComment, GroupComment } from 'common/comment'
import { import {
Comment,
listenForCommentsOnContract, listenForCommentsOnContract,
listenForCommentsOnGroup, listenForCommentsOnGroup,
listenForRecentComments, listenForRecentComments,
} from 'web/lib/firebase/comments' } from 'web/lib/firebase/comments'
export const useComments = (contractId: string) => { export const useComments = (contractId: string) => {
const [comments, setComments] = useState<Comment[] | undefined>() const [comments, setComments] = useState<ContractComment[] | undefined>()
useEffect(() => { useEffect(() => {
if (contractId) return listenForCommentsOnContract(contractId, setComments) if (contractId) return listenForCommentsOnContract(contractId, setComments)
@ -16,7 +16,7 @@ export const useComments = (contractId: string) => {
return comments return comments
} }
export const useCommentsOnGroup = (groupId: string | undefined) => { export const useCommentsOnGroup = (groupId: string | undefined) => {
const [comments, setComments] = useState<Comment[] | undefined>() const [comments, setComments] = useState<GroupComment[] | undefined>()
useEffect(() => { useEffect(() => {
if (groupId) return listenForCommentsOnGroup(groupId, setComments) if (groupId) return listenForCommentsOnGroup(groupId, setComments)

View File

@ -29,13 +29,11 @@ export async function createChallenge(data: {
creatorAmount: number creatorAmount: number
acceptorAmount: number acceptorAmount: number
expiresTime: number | null expiresTime: number | null
message: string
}) { }) {
const { const {
creator, creator,
creatorAmount, creatorAmount,
expiresTime, expiresTime,
message,
contract, contract,
outcome, outcome,
acceptorAmount, acceptorAmount,
@ -73,7 +71,7 @@ export async function createChallenge(data: {
acceptedByUserIds: [], acceptedByUserIds: [],
acceptances: [], acceptances: [],
isResolved: false, isResolved: false,
message, message: '',
} }
await setDoc(doc(challenges(contract.id), slug), challenge) await setDoc(doc(challenges(contract.id), slug), challenge)

View File

@ -11,7 +11,7 @@ import {
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { db } from './init' import { db } from './init'
import { User } from 'common/user' import { User } from 'common/user'
import { Comment } from 'common/comment' import { Comment, ContractComment, GroupComment } from 'common/comment'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react' import { JSONContent } from '@tiptap/react'
@ -31,8 +31,10 @@ export async function createCommentOnContract(
const ref = betId const ref = betId
? doc(getCommentsCollection(contractId), betId) ? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId)) : doc(getCommentsCollection(contractId))
const comment: Comment = removeUndefinedProps({ // contract slug and question are set via trigger
const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
commentType: 'contract',
contractId, contractId,
userId: commenter.id, userId: commenter.id,
content: content, content: content,
@ -59,8 +61,9 @@ export async function createCommentOnGroup(
replyToCommentId?: string replyToCommentId?: string
) { ) {
const ref = doc(getCommentsOnGroupCollection(groupId)) const ref = doc(getCommentsOnGroupCollection(groupId))
const comment: Comment = removeUndefinedProps({ const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
commentType: 'group',
groupId, groupId,
userId: user.id, userId: user.id,
content: content, content: content,
@ -94,7 +97,7 @@ export async function listAllComments(contractId: string) {
} }
export async function listAllCommentsOnGroup(groupId: string) { export async function listAllCommentsOnGroup(groupId: string) {
const comments = await getValues<Comment>( const comments = await getValues<GroupComment>(
getCommentsOnGroupCollection(groupId) getCommentsOnGroupCollection(groupId)
) )
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -103,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) {
export function listenForCommentsOnContract( export function listenForCommentsOnContract(
contractId: string, contractId: string,
setComments: (comments: Comment[]) => void setComments: (comments: ContractComment[]) => void
) { ) {
return listenForValues<Comment>( return listenForValues<ContractComment>(
getCommentsCollection(contractId), getCommentsCollection(contractId),
(comments) => { (comments) => {
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -115,9 +118,9 @@ export function listenForCommentsOnContract(
} }
export function listenForCommentsOnGroup( export function listenForCommentsOnGroup(
groupId: string, groupId: string,
setComments: (comments: Comment[]) => void setComments: (comments: GroupComment[]) => void
) { ) {
return listenForValues<Comment>( return listenForValues<GroupComment>(
getCommentsOnGroupCollection(groupId), getCommentsOnGroupCollection(groupId),
(comments) => { (comments) => {
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)

View File

@ -22,7 +22,6 @@ import { createRNG, shuffle } from 'common/util/random'
import { getCpmmProbability } from 'common/calculate-cpmm' import { getCpmmProbability } from 'common/calculate-cpmm'
import { formatMoney, formatPercent } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
@ -285,16 +284,6 @@ export async function getContractsBySlugs(slugs: string[]) {
return sortBy(data, (contract) => -1 * contract.volume24Hours) return sortBy(data, (contract) => -1 * contract.volume24Hours)
} }
const topWeeklyQuery = query(
contracts,
where('isResolved', '==', false),
orderBy('volume7Days', 'desc'),
limit(MAX_FEED_CONTRACTS)
)
export async function getTopWeeklyContracts() {
return await getValues<Contract>(topWeeklyQuery)
}
const closingSoonQuery = query( const closingSoonQuery = query(
contracts, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),

View File

@ -14,20 +14,10 @@ import {
onSnapshot, onSnapshot,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
import { zip } from 'lodash'
import { app, db } from './init' import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { import { coll, getValues, listenForValue, listenForValues } from './utils'
coll,
getValue,
getValues,
listenForValue,
listenForValues,
} from './utils'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local' import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { addUserToGroupViaId } from 'web/lib/firebase/groups'
@ -202,20 +192,6 @@ export async function firebaseLogout() {
await auth.signOut() await auth.signOut()
} }
const storage = getStorage(app)
// Example: uploadData('avatars/ajfi8iejsf.png', data)
export async function uploadData(
path: string,
data: ArrayBuffer | Blob | Uint8Array
) {
const uploadRef = ref(storage, path)
// Uploaded files should be cached for 1 day, then revalidated
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' }
await uploadBytes(uploadRef, data, metadata)
return await getDownloadURL(uploadRef)
}
export async function listUsers(userIds: string[]) { export async function listUsers(userIds: string[]) {
if (userIds.length > 10) { if (userIds.length > 10) {
throw new Error('Too many users requested at once; Firestore limits to 10') throw new Error('Too many users requested at once; Firestore limits to 10')
@ -263,25 +239,6 @@ export function getUsers() {
return getValues<User>(users) return getValues<User>(users)
} }
export async function getUserFeed(userId: string) {
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
const userFeed = await getValue<{
feed: feed
}>(feedDoc)
return userFeed?.feed ?? []
}
export async function getCategoryFeeds(userId: string) {
const cacheCollection = collection(privateUsers, userId, 'cache')
const feedData = await Promise.all(
CATEGORY_LIST.map((category) =>
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
)
)
const feeds = feedData.map((data) => data?.feed ?? [])
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][])
}
export async function follow(userId: string, followedUserId: string) { export async function follow(userId: string, followedUserId: string) {
const followDoc = doc(collection(users, userId, 'follows'), followedUserId) const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
await setDoc(followDoc, { await setDoc(followDoc, {

View File

@ -1,5 +1,7 @@
const API_DOCS_URL = 'https://docs.manifold.markets/api' const API_DOCS_URL = 'https://docs.manifold.markets/api'
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
module.exports = { module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page staticPageGenerationTimeout: 600, // e.g. stats page
@ -35,6 +37,11 @@ module.exports = {
destination: API_DOCS_URL, destination: API_DOCS_URL,
permanent: false, permanent: false,
}, },
{
source: '/about',
destination: ABOUT_PAGE_URL,
permanent: false,
},
{ {
source: '/analytics', source: '/analytics',
destination: '/stats', destination: '/stats',

View File

@ -74,6 +74,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.1.0", "csstype": "^3.1.0",
"eslint-config-next": "12.1.6", "eslint-config-next": "12.1.6",
"eslint-config-prettier": "8.5.0",
"next-sitemap": "^2.5.14", "next-sitemap": "^2.5.14",
"postcss": "8.3.5", "postcss": "8.3.5",
"prettier-plugin-tailwindcss": "^0.1.5", "prettier-plugin-tailwindcss": "^0.1.5",

View File

@ -17,7 +17,7 @@ import {
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Bet, listAllBets } from 'web/lib/firebase/bets' import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { Comment, listAllComments } from 'web/lib/firebase/comments' import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404' import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel' import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
@ -38,6 +38,7 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { listUsers } from 'web/lib/firebase/users' import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments' import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
@ -78,7 +79,7 @@ export default function ContractPage(props: {
contract: Contract | null contract: Contract | null
username: string username: string
bets: Bet[] bets: Bet[]
comments: Comment[] comments: ContractComment[]
slug: string slug: string
backToHome?: () => void backToHome?: () => void
}) { }) {
@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
function ContractTopTrades(props: { function ContractTopTrades(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
comments: Comment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, bets, comments, tips } = props const { contract, bets, comments, tips } = props

View File

@ -7,6 +7,7 @@ import { User } from 'common/user'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { richTextToString } from 'common/util/parse'
export type LiteMarket = { export type LiteMarket = {
// Unique identifer for this market // Unique identifer for this market
@ -22,6 +23,7 @@ export type LiteMarket = {
closeTime?: number closeTime?: number
question: string question: string
description: string | JSONContent description: string | JSONContent
textDescription: string // string version of description
tags: string[] tags: string[]
url: string url: string
outcomeType: string outcomeType: string
@ -40,6 +42,8 @@ export type LiteMarket = {
resolution?: string resolution?: string
resolutionTime?: number resolutionTime?: number
resolutionProbability?: number resolutionProbability?: number
lastUpdatedTime?: number
} }
export type ApiAnswer = Answer & { export type ApiAnswer = Answer & {
@ -90,6 +94,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
resolution, resolution,
resolutionTime, resolutionTime,
resolutionProbability, resolutionProbability,
lastUpdatedTime,
} = contract } = contract
const { p, totalLiquidity } = contract as any const { p, totalLiquidity } = contract as any
@ -97,6 +102,11 @@ export function toLiteMarket(contract: Contract): LiteMarket {
const probability = const probability =
contract.outcomeType === 'BINARY' ? getProbability(contract) : undefined contract.outcomeType === 'BINARY' ? getProbability(contract) : undefined
let min, max, isLogScale: any
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
;({ min, max, isLogScale } = contract)
}
return removeUndefinedProps({ return removeUndefinedProps({
id, id,
creatorUsername, creatorUsername,
@ -109,6 +119,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
: closeTime, : closeTime,
question, question,
description, description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
tags, tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`, url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool, pool,
@ -124,6 +138,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
resolution, resolution,
resolutionTime, resolutionTime,
resolutionProbability, resolutionProbability,
lastUpdatedTime,
min,
max,
isLogScale,
}) })
} }

View File

@ -16,7 +16,7 @@ import {
useAcceptedChallenges, useAcceptedChallenges,
useUserChallenges, useUserChallenges,
} from 'web/lib/firebase/challenges' } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge' import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
@ -29,6 +29,7 @@ import { copyToClipboard } from 'web/lib/util/copy'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { QRCode } from 'web/components/qr-code' import { QRCode } from 'web/components/qr-code'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
@ -37,6 +38,7 @@ const amountClass = columnClass + ' max-w-[75px] font-bold'
export default function ChallengesListPage() { export default function ChallengesListPage() {
const user = useUser() const user = useUser()
const challenges = useAcceptedChallenges() const challenges = useAcceptedChallenges()
const [open, setOpen] = React.useState(false)
const userChallenges = useUserChallenges(user?.id) const userChallenges = useUserChallenges(user?.id)
.concat( .concat(
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : [] user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
@ -70,8 +72,25 @@ export default function ChallengesListPage() {
<Col className="w-full px-8"> <Col className="w-full px-8">
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<Title text="Challenges" /> <Title text="Challenges" />
{CHALLENGES_ENABLED && (
<Button size="lg" color="gradient" onClick={() => setOpen(true)}>
Create Challenge
<CreateChallengeModal
isOpen={open}
setOpen={setOpen}
user={user}
/>
</Button>
)}
</Row> </Row>
<p>Find or create a question to challenge someone to a bet.</p> <p>
Want to create your own challenge?
<SiteLink className={'mx-1 font-bold'} href={'/home'}>
Find
</SiteLink>
a market you and a friend disagree on and hit the challenge button, or
tap the button above to create a new market & challenge in one.
</p>
<Tabs tabs={[...userTab, ...publicTab]} /> <Tabs tabs={[...userTab, ...publicTab]} />
</Col> </Col>

View File

@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { Comment } from 'common/comment' import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat' import { GroupChat } from 'web/components/groups/group-chat'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
@ -123,7 +123,7 @@ export default function GroupPage(props: {
topTraders: User[] topTraders: User[]
creatorScores: { [userId: string]: number } creatorScores: { [userId: string]: number }
topCreators: User[] topCreators: User[]
messages: Comment[] messages: GroupComment[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
group: null, group: null,
@ -607,7 +607,9 @@ function AddContractButton(props: { group: Group; user: User }) {
user={user} user={user}
hideOrderSelector={true} hideOrderSelector={true}
onContractClick={addContractToCurrentGroup} 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 }} cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{ excludeContractIds: group.contractIds }} additionalFilter={{ excludeContractIds: group.contractIds }}
highlightOptions={{ highlightOptions={{

View File

@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } const creds = await authenticateOnServer(ctx)
}) const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } }) => { const Home = (props: { auth: { user: User } | null }) => {
const { user } = props.auth const user = props.auth ? props.auth.user : null
const [contract, setContract] = useContractPage() const [contract, setContract] = useContractPage()
const router = useRouter() const router = useRouter()
@ -42,6 +45,7 @@ const Home = (props: { auth: { user: User } }) => {
// Update the url without switching pages in Nextjs. // Update the url without switching pages in Nextjs.
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`) history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
}} }}
isWholePage
/> />
</Col> </Col>
<button <button

View File

@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) {
<Col className="items-center"> <Col className="items-center">
<Col className="max-w-3xl"> <Col className="max-w-3xl">
<LandingPagePanel hotContracts={hotContracts ?? []} /> <LandingPagePanel hotContracts={hotContracts ?? []} />
{/* <p className="mt-6 text-gray-500">
View{' '}
<SiteLink href="/markets" className="font-bold text-gray-700">
all markets
</SiteLink>
</p> */}
</Col> </Col>
</Col> </Col>
</Page> </Page>

View File

@ -1,19 +0,0 @@
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../components/contract-search'
import { Page } from '../components/page'
import { SEO } from '../components/SEO'
// TODO: Rename endpoint to "Explore"
export default function Markets() {
const user = useUser()
return (
<Page>
<SEO
title="Explore"
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
url="/markets"
/>
<ContractSearch user={user} />
</Page>
)
}

View File

@ -1,24 +0,0 @@
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../../components/contract-search'
import { Page } from '../../components/page'
import { Title } from '../../components/title'
export default function TagPage() {
const router = useRouter()
const user = useUser()
const { tag } = router.query as { tag: string }
if (!router.isReady) return <div />
return (
<Page>
<Title text={`#${tag}`} />
<ContractSearch
user={user}
defaultSort="newest"
defaultFilter="all"
additionalFilter={{ tag }}
/>
</Page>
)
}

View File

@ -18,7 +18,9 @@ online = false
firstPrint = false firstPrint = false
flag = true flag = true
page = 1 page = 1
sets = {}
window.console.log(sets)
document.location.search.split('&').forEach((pair) => { document.location.search.split('&').forEach((pair) => {
let v = pair.split('=') let v = pair.split('=')
if (v[0] === '?whichguesser') { 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) fetchToResponse(firstFetch)
function putIntoMapAndFetch(data) { function putIntoMapAndFetch(data) {
putIntoMap(data.data) putIntoMap(data.data)
if (data.has_more) { for (const [key, value] of Object.entries(allData)) {
page += 1 nameList.push(key)
window.setTimeout(() => probList.push(
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json')) value.length + (probList.length === 0 ? 0 : probList[probList.length - 1])
) )
} else { unseenTotal = total
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()
} }
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() { function getKSamples() {
@ -134,11 +135,21 @@ function determineIfSkip(card) {
} }
} }
if (firstPrint) { if (firstPrint) {
if ( if (whichGuesser == 'basic') {
card.reprint === true || if (
(card.frame_effects && card.frame_effects.includes('showcase')) card.set_type !== 'expansion' &&
) { card.set_type !== 'funny' &&
return true 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 // reskinned card names show in art crop
@ -160,13 +171,16 @@ function putIntoMap(data) {
if (card.card_faces) { if (card.card_faces) {
name = card.card_faces[0].name 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 = '' let normalImg = ''
if (card.image_uris.normal) { if (card.image_uris.normal) {
normalImg = 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 { } else {
continue continue
} }
@ -211,7 +225,9 @@ function setUpNewGame() {
artDict = sampledData[0] artDict = sampledData[0]
let randomImages = Object.keys(artDict) let randomImages = Object.keys(artDict)
shuffleArray(randomImages) 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 // fill in the new cards and names
for (let cardIndex = 1; cardIndex <= k; cardIndex++) { for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
let currCard = document.getElementById('card-' + cardIndex) let currCard = document.getElementById('card-' + cardIndex)
@ -224,11 +240,16 @@ function setUpNewGame() {
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) { for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
currName = document.getElementById('name-' + nameIndex) currName = document.getElementById('name-' + nameIndex)
// window.console.log(currName) // window.console.log(currName)
currName.innerText = namesList[nameIndex - 1] currName.innerHTML = namesList[nameIndex - 1]
nameBank.appendChild(currName) nameBank.appendChild(currName)
} }
} }
function removeSymbol(name) {
let arr = name.split('>')
return arr[arr.length - 1]
}
function checkAnswers() { function checkAnswers() {
let score = k let score = k
// show the correct full cards // show the correct full cards
@ -236,9 +257,13 @@ function checkAnswers() {
currCard = document.getElementById('card-' + cardIndex) currCard = document.getElementById('card-' + cardIndex)
let incorrect = true let incorrect = true
if (currCard.dataset.name) { if (currCard.dataset.name) {
let guess = document.getElementById(currCard.dataset.name).innerText // remove image text
// window.console.log(artDict[currCard.dataset.url][0], guess); let guess = removeSymbol(
incorrect = artDict[currCard.dataset.url][0] !== guess 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 // decide if their guess was correct
} }
if (incorrect) currCard.classList.add('incorrect') if (incorrect) currCard.classList.add('incorrect')
@ -352,6 +377,10 @@ function dropOnCard(id, data) {
} }
function setWordsLeft() { function setWordsLeft() {
cardName = 'Unused Card Names: '
if (whichGuesser === 'basic') {
cardName = 'Unused Set Names: '
}
document.getElementById('words-left').innerText = document.getElementById('words-left').innerText =
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft cardName + wordsLeft + '/Images: ' + imagesLeft
} }

View File

@ -77,7 +77,7 @@
} }
.answer-page .card { .answer-page .card {
height: 350px; height: 353px;
/*padding-top: 310px;*/ /*padding-top: 310px;*/
/*background-size: cover;*/ /*background-size: cover;*/
overflow: hidden; overflow: hidden;
@ -253,16 +253,6 @@
.name { .name {
width: 300px; width: 300px;
} }
.card {
width: 300px;
background-size: 300px;
height: 266px;
}
.answer-page .card {
height: 454px;
}
} }
</style> </style>
</head> </head>

View File

@ -3,7 +3,8 @@ import requests
import json import json
# add category name here # add category name here
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn'] allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
specialCategories = ['set', 'basic']
def generate_initial_query(category): def generate_initial_query(category):
@ -12,11 +13,11 @@ def generate_initial_query(category):
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure' string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
elif category == 'beast': elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken' string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
elif category == 'terror': # elif category == 'terror':
string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \ # string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
'%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure' # '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
elif category == 'wrath': # elif category == 'wrath':
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure' # string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
elif category == 'burn': elif category == 'burn':
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \ 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' \ '%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' '+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
# add category string query here # 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' \ 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' \ '<%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' \ '%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=' '+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) print(string_query)
return string_query return string_query
@ -34,31 +45,60 @@ def generate_initial_query(category):
def fetch_and_write_all(category, query): def fetch_and_write_all(category, query):
count = 1 count = 1
will_repeat = True will_repeat = True
all_cards = {'data' : []}
art_names = set()
while will_repeat: while will_repeat:
will_repeat = fetch_and_write(category, query, count) response = fetch(query, count)
count += 1 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) query += str(count)
response = requests.get(f"{query}").json() response = requests.get(f"{query}").json()
time.sleep(0.1) time.sleep(0.1)
with open('jsons/' + category + str(count) + '.json', 'w') as f: return response
json.dump(to_compact_write_form(response), f)
return response['has_more'] def fetch_special(query):
response = requests.get(f"{query}").json()
time.sleep(0.1)
return response
def to_compact_write_form(response): def to_compact_write_form(smallJson, art_names, response, category):
fieldsToUse = ['has_more']
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital', fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
'set_type'] 'set_type']
smallJson = dict()
data = [] data = []
# write all fields needed in response
for field in fieldsToUse:
smallJson[field] = response[field]
# write all fields needed in card # write all fields needed in card
for card in response['data']: 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() write_card = dict()
for field in fieldsInCard: for field in fieldsInCard:
if field == 'name' and 'card_faces' in card: if field == 'name' and 'card_faces' in card:
@ -68,8 +108,33 @@ def to_compact_write_form(response):
elif field in card: elif field in card:
write_card[field] = card[field] write_card[field] = card[field]
data.append(write_card) data.append(write_card)
smallJson['data'] = data smallJson['data'] += data
return smallJson
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 # only write images needed
@ -87,6 +152,9 @@ def write_image_uris(card_image_uris):
if __name__ == "__main__": 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) print(category)
fetch_and_write_all(category, generate_initial_query(category)) fetch_and_write_all_special(category, generate_initial_special_query(category))

View File

@ -159,6 +159,16 @@
> >
<br /> <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"> <details id="addl-options">
<summary> <summary>
<img <img
@ -174,7 +184,7 @@
<label for="un">include un-cards</label> <label for="un">include un-cards</label>
<br /> <br />
<input type="checkbox" name="original" id="original" /> <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> </details>
<input type="submit" id="submit" value="Play" /> <input type="submit" id="submit" value="Play" />
</form> </form>

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url> <url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.2</priority></url> <url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url> <url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
</urlset> </urlset>

View File

@ -5764,6 +5764,11 @@ eslint-config-next@12.1.6:
eslint-plugin-react "^7.29.4" eslint-plugin-react "^7.29.4"
eslint-plugin-react-hooks "^4.5.0" 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: eslint-import-resolver-node@^0.3.6:
version "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" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd"