Merge branch 'main' into markets-emails
This commit is contained in:
commit
a536d38376
|
@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people.
|
|||
Reduce the number of easily preventable deaths worldwide.
|
||||
Work towards sustainable, systemic change.`,
|
||||
},
|
||||
{
|
||||
name: 'YIMBY Law',
|
||||
website: 'https://www.yimbylaw.org/',
|
||||
photo: 'https://i.imgur.com/zlzp21Z.png',
|
||||
preview:
|
||||
'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.',
|
||||
description: `
|
||||
YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply.
|
||||
|
||||
If you would like to support our work, you can do so by getting involved or by donating.`,
|
||||
},
|
||||
{
|
||||
name: 'CaRLA',
|
||||
website: 'https://carlaef.org/',
|
||||
photo: 'https://i.imgur.com/IsNVTOY.png',
|
||||
preview:
|
||||
'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.',
|
||||
description: `
|
||||
The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.
|
||||
|
||||
CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage.
|
||||
|
||||
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export type AnyCommentType = OnContract | OnGroup
|
||||
|
||||
// Currently, comments are created after the bet, not atomically with the bet.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
export type Comment = {
|
||||
export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||
id: string
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
betId?: string
|
||||
answerOutcome?: string
|
||||
replyToCommentId?: string
|
||||
userId: string
|
||||
|
||||
|
@ -20,6 +18,21 @@ export type Comment = {
|
|||
userName: string
|
||||
userUsername: string
|
||||
userAvatarUrl?: string
|
||||
contractSlug?: string
|
||||
contractQuestion?: string
|
||||
} & T
|
||||
|
||||
type OnContract = {
|
||||
commentType: 'contract'
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
answerOutcome?: string
|
||||
betId?: string
|
||||
}
|
||||
|
||||
type OnGroup = {
|
||||
commentType: 'group'
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type ContractComment = Comment<OnContract>
|
||||
export type GroupComment = Comment<OnGroup>
|
||||
|
|
|
@ -38,6 +38,7 @@ export type notification_source_types =
|
|||
| 'user'
|
||||
| 'bonus'
|
||||
| 'challenge'
|
||||
| 'betting_streak_bonus'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -66,3 +67,4 @@ export type notification_reason_types =
|
|||
| 'bet_fill'
|
||||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
|
|
|
@ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005
|
|||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT = 5
|
||||
export const BETTING_STREAK_RESET_HOUR = 9
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { ClickEvent } from './tracking'
|
||||
import { filterDefined } from './util/array'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
export const MAX_FEED_CONTRACTS = 75
|
||||
|
||||
export const getRecommendedContracts = (
|
||||
contractsById: { [contractId: string]: Contract },
|
||||
yourBetOnContractIds: string[]
|
||||
) => {
|
||||
const contracts = Object.values(contractsById)
|
||||
const yourContracts = filterDefined(
|
||||
yourBetOnContractIds.map((contractId) => contractsById[contractId])
|
||||
)
|
||||
|
||||
const yourContractIds = new Set(yourContracts.map((c) => c.id))
|
||||
const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id))
|
||||
|
||||
const yourWordFrequency = contractsToWordFrequency(yourContracts)
|
||||
const otherWordFrequency = contractsToWordFrequency(notYourContracts)
|
||||
const words = union(
|
||||
Object.keys(yourWordFrequency),
|
||||
Object.keys(otherWordFrequency)
|
||||
)
|
||||
|
||||
const yourWeightedFrequency = Object.fromEntries(
|
||||
words.map((word) => {
|
||||
const [yourFreq, otherFreq] = [
|
||||
yourWordFrequency[word] ?? 0,
|
||||
otherWordFrequency[word] ?? 0,
|
||||
]
|
||||
|
||||
const score = yourFreq / (yourFreq + otherFreq + 0.0001)
|
||||
|
||||
return [word, score]
|
||||
})
|
||||
)
|
||||
|
||||
// console.log(
|
||||
// 'your weighted frequency',
|
||||
// _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq)
|
||||
// )
|
||||
|
||||
const scoredContracts = contracts.map((contract) => {
|
||||
const wordFrequency = contractToWordFrequency(contract)
|
||||
|
||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
||||
const wordFreq = wordFrequency[word] ?? 0
|
||||
const weight = yourWeightedFrequency[word] ?? 0
|
||||
return wordFreq * weight
|
||||
})
|
||||
|
||||
return {
|
||||
contract,
|
||||
score,
|
||||
}
|
||||
})
|
||||
|
||||
return sortBy(scoredContracts, (scored) => -scored.score).map(
|
||||
(scored) => scored.contract
|
||||
)
|
||||
}
|
||||
|
||||
const contractToText = (contract: Contract) => {
|
||||
const { description, question, tags, creatorUsername } = contract
|
||||
return `${creatorUsername} ${question} ${tags.join(' ')} ${description}`
|
||||
}
|
||||
|
||||
const MAX_CHARS_IN_WORD = 100
|
||||
|
||||
const getWordsCount = (text: string) => {
|
||||
const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase()
|
||||
const words = normalizedText
|
||||
.split(' ')
|
||||
.filter((word) => word)
|
||||
.filter((word) => word.length <= MAX_CHARS_IN_WORD)
|
||||
|
||||
const counts: { [word: string]: number } = {}
|
||||
for (const word of words) {
|
||||
if (counts[word]) counts[word]++
|
||||
else counts[word] = 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const toFrequency = (counts: { [word: string]: number }) => {
|
||||
const total = sum(Object.values(counts))
|
||||
return mapValues(counts, (count) => count / total)
|
||||
}
|
||||
|
||||
const contractToWordFrequency = (contract: Contract) =>
|
||||
toFrequency(getWordsCount(contractToText(contract)))
|
||||
|
||||
const contractsToWordFrequency = (contracts: Contract[]) => {
|
||||
const frequencySum = contracts
|
||||
.map(contractToWordFrequency)
|
||||
.reduce(addObjects, {})
|
||||
|
||||
return toFrequency(frequencySum)
|
||||
}
|
||||
|
||||
export const getWordScores = (
|
||||
contracts: Contract[],
|
||||
contractViewCounts: { [contractId: string]: number },
|
||||
clicks: ClickEvent[],
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const contractClicks = groupBy(clicks, (click) => click.contractId)
|
||||
const contractBets = groupBy(bets, (bet) => bet.contractId)
|
||||
|
||||
const yourContracts = contracts.filter(
|
||||
(c) =>
|
||||
contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id]
|
||||
)
|
||||
const yourTfIdf = calculateContractTfIdf(yourContracts)
|
||||
|
||||
const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
|
||||
const viewCount = contractViewCounts[contractId] ?? 0
|
||||
const clickCount = contractClicks[contractId]?.length ?? 0
|
||||
const betCount = contractBets[contractId]?.length ?? 0
|
||||
|
||||
const factor =
|
||||
-1 * Math.log(viewCount + 1) +
|
||||
10 * Math.log(betCount + clickCount / 4 + 1)
|
||||
|
||||
return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
|
||||
})
|
||||
|
||||
const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
|
||||
const minScore = Math.min(...Object.values(wordScores))
|
||||
const maxScore = Math.max(...Object.values(wordScores))
|
||||
const normalizedWordScores = mapValues(
|
||||
wordScores,
|
||||
(score) => (score - minScore) / (maxScore - minScore)
|
||||
)
|
||||
|
||||
// console.log(
|
||||
// 'your word scores',
|
||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100),
|
||||
// _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100)
|
||||
// )
|
||||
|
||||
return normalizedWordScores
|
||||
}
|
||||
|
||||
export function getContractScore(
|
||||
contract: Contract,
|
||||
wordScores: { [word: string]: number }
|
||||
) {
|
||||
if (Object.keys(wordScores).length === 0) return 1
|
||||
|
||||
const wordFrequency = contractToWordFrequency(contract)
|
||||
const score = sumBy(Object.keys(wordFrequency), (word) => {
|
||||
const wordFreq = wordFrequency[word] ?? 0
|
||||
const weight = wordScores[word] ?? 0
|
||||
return wordFreq * weight
|
||||
})
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF):
|
||||
// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736
|
||||
function calculateContractTfIdf(contracts: Contract[]) {
|
||||
const contractFreq = contracts.map((c) => contractToWordFrequency(c))
|
||||
const contractWords = contractFreq.map((freq) => Object.keys(freq))
|
||||
|
||||
const wordsCount: { [word: string]: number } = {}
|
||||
for (const words of contractWords) {
|
||||
for (const word of words) {
|
||||
wordsCount[word] = (wordsCount[word] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const wordIdf = mapValues(wordsCount, (count) =>
|
||||
Math.log(contracts.length / count)
|
||||
)
|
||||
const contractWordsTfIdf = contractFreq.map((wordFreq) =>
|
||||
mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
|
||||
)
|
||||
return Object.fromEntries(
|
||||
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
|
||||
)
|
||||
}
|
|
@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
||||
category:
|
||||
| 'CHARITY'
|
||||
| 'MANALINK'
|
||||
| 'TIP'
|
||||
| 'REFERRAL'
|
||||
| 'UNIQUE_BETTOR_BONUS'
|
||||
| 'BETTING_STREAK_BONUS'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
@ -57,7 +63,7 @@ type Referral = {
|
|||
type Bonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
|
|
|
@ -41,6 +41,8 @@ export type User = {
|
|||
referredByGroupId?: string
|
||||
lastPingTime?: number
|
||||
shouldShowWelcome?: boolean
|
||||
lastBetTime?: number
|
||||
currentBettingStreak?: number
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
|
|
|
@ -135,7 +135,8 @@ Requires no authorization.
|
|||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||
question: string
|
||||
description: string
|
||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||
textDescription: string // string description without formatting, images, or embeds
|
||||
|
||||
// A list of tags on each market. Any user can add tags to any market.
|
||||
// This list also includes the predefined categories shown as filters on the home page.
|
||||
|
@ -162,6 +163,8 @@ Requires no authorization.
|
|||
resolutionTime?: number
|
||||
resolution?: string
|
||||
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
||||
|
||||
lastUpdatedTime?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -541,6 +544,7 @@ Parameters:
|
|||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||
- `question`: Required. The headline question for the market.
|
||||
- `description`: Required. A long description describing the rules for the market.
|
||||
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||
- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch.
|
||||
- `tags`: Optional. An array of string tags for the market.
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||
- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API
|
||||
|
||||
## Bots
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet'
|
|||
import { createChallengeAcceptedNotification } from './create-notification'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => {
|
|||
return yourNewBetDoc
|
||||
})
|
||||
|
||||
await redeemShares(auth.uid, contractId)
|
||||
|
||||
return { betId: result.id }
|
||||
})
|
||||
|
|
|
@ -14,15 +14,17 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { chargeUser, getContract, isProd } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
FIXED_ANTE,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
|
@ -59,7 +61,7 @@ const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
|||
|
||||
const bodySchema = z.object({
|
||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||
description: descScehma.optional(),
|
||||
description: descScehma.or(z.string()).optional(),
|
||||
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
|
||||
closeTime: zTimestamp().refine(
|
||||
(date) => date.getTime() > new Date().getTime(),
|
||||
|
@ -165,13 +167,27 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
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,
|
||||
description ?? {},
|
||||
newDescription,
|
||||
initialProb ?? 0,
|
||||
ante,
|
||||
closeTime.getTime(),
|
||||
|
@ -197,7 +213,9 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const liquidityDoc = firestore
|
|
@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async (
|
|||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
export const createBettingStreakBonusNotification = async (
|
||||
user: User,
|
||||
txnId: string,
|
||||
bet: Bet,
|
||||
contract: Contract,
|
||||
amount: number,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${user.id}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: user.id,
|
||||
reason: 'betting_streak_incremented',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: txnId,
|
||||
sourceType: 'betting_streak_bonus',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: user.name,
|
||||
sourceUserUsername: user.username,
|
||||
sourceUserAvatarUrl: user.avatarUrl,
|
||||
sourceText: amount.toString(),
|
||||
sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`,
|
||||
sourceTitle: 'Betting Streak Bonus',
|
||||
// Perhaps not necessary, but just in case
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
cleanDisplayName,
|
||||
cleanUsername,
|
||||
} from '../../common/util/clean-username'
|
||||
import { sendWelcomeEmail } from './emails'
|
||||
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
||||
import { isWhitelisted } from '../../common/envs/constants'
|
||||
import {
|
||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
||||
|
@ -96,6 +96,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
|
||||
await addUserToDefaultGroups(user)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await sendPersonalFollowupEmail(user, privateUser)
|
||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||
|
||||
return { user, privateUser }
|
||||
|
|
|
@ -128,7 +128,20 @@
|
|||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hi {{name}},</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||
using Manifold Markets. Running low
|
||||
|
@ -161,6 +174,51 @@
|
|||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides making correct predictions, there are
|
||||
plenty of other ways to earn mana?</span></p>
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||
tips on comments</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||
trader bonus for each user who bets on your
|
||||
markets</span></li>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||
friends</u></span></a></span></li>
|
||||
<li style="line-height:23px;"><a class="link-build-content"
|
||||
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||
feedback</u></span></a></li>
|
||||
</ul>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -188,6 +188,56 @@
|
|||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides betting and making predictions, you can also <a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/create"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;font-weight: bold;"><u>create
|
||||
your
|
||||
own
|
||||
market</u></span></a> on
|
||||
any question you care about?</span></p>
|
||||
|
||||
<p>More resources:</p>
|
||||
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/about"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Learn more</u></span></a>
|
||||
about Manifold and how our markets work</span></li>
|
||||
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
||||
your friends</u></span></a> and earn M$500 for each signup!</span></li>
|
||||
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://discord.com/invite/eHQBNBqXuh"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
|
||||
chat</u></span></a></span></li>
|
||||
</ul>
|
||||
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import * as dayjs from 'dayjs'
|
||||
|
||||
import { DOMAIN } from '../../common/envs/constants'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
@ -14,7 +16,7 @@ import {
|
|||
import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail } from './send-email'
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
|
@ -166,6 +168,43 @@ export const sendWelcomeEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const sendPersonalFollowupEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const emailBody = `Hi ${firstName},
|
||||
|
||||
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
|
||||
|
||||
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
||||
|
||||
Feel free to reply to this email with any questions or concerns you have.
|
||||
|
||||
Cheers,
|
||||
|
||||
James
|
||||
Cofounder of Manifold Markets
|
||||
https://manifold.markets
|
||||
`
|
||||
|
||||
const sendTime = dayjs().add(4, 'hours').toString()
|
||||
|
||||
await sendTextEmail(
|
||||
privateUser.email,
|
||||
'How are you finding Manifold?',
|
||||
emailBody,
|
||||
{
|
||||
from: 'James from Manifold <james@manifold.markets>',
|
||||
'o:deliverytime': sendTime,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendOneWeekBonusEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
@ -198,6 +237,37 @@ export const sendOneWeekBonusEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const sendCreatorGuideEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
privateUser.unsubscribedFromGenericEmails
|
||||
)
|
||||
return
|
||||
|
||||
const { name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const emailType = 'generic'
|
||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Market creation guide',
|
||||
'creating-market',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendThankYouEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
|
|
@ -26,6 +26,8 @@ export * from './on-create-txn'
|
|||
export * from './on-delete-group'
|
||||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -38,7 +40,7 @@ export * from './cancel-bet'
|
|||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-contract'
|
||||
export * from './create-market'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
|
@ -57,7 +59,7 @@ import { cancelbet } from './cancel-bet'
|
|||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
|
|
|
@ -3,15 +3,20 @@ import * as admin from 'firebase-admin'
|
|||
import { keyBy, uniq } from 'lodash'
|
||||
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
import { getContract, getUser, getValues, isProd, log } from './utils'
|
||||
import { getUser, getValues, isProd, log } from './utils'
|
||||
import {
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
createNotification,
|
||||
} from './create-notification'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
|
||||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
BETTING_STREAK_RESET_HOUR,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
} from '../../common/numeric-constants'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore
|
|||
.doc(contractId)
|
||||
.update({ lastBetTime, lastUpdatedTime: Date.now() })
|
||||
|
||||
await notifyFills(bet, contractId, eventId)
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(
|
||||
contractId,
|
||||
eventId,
|
||||
bet.userId
|
||||
)
|
||||
const userContractSnap = await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.get()
|
||||
const contract = userContractSnap.data() as Contract
|
||||
|
||||
if (!contract) {
|
||||
log(`Could not find contract ${contractId}`)
|
||||
return
|
||||
}
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
|
||||
|
||||
const bettor = await getUser(bet.userId)
|
||||
if (!bettor) return
|
||||
|
||||
await notifyFills(bet, contract, eventId, bettor)
|
||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||
|
||||
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
|
||||
})
|
||||
|
||||
const updateBettingStreak = async (
|
||||
user: User,
|
||||
bet: Bet,
|
||||
contract: Contract,
|
||||
eventId: string
|
||||
) => {
|
||||
const betStreakResetTime = getTodaysBettingStreakResetTime()
|
||||
const lastBetTime = user?.lastBetTime ?? 0
|
||||
|
||||
// If they've already bet after the reset time, or if we haven't hit the reset time yet
|
||||
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
|
||||
return
|
||||
|
||||
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
||||
// Otherwise, add 1 to their betting streak
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
currentBettingStreak: newBettingStreak,
|
||||
})
|
||||
|
||||
// Send them the bonus times their streak
|
||||
const bonusAmount = Math.min(
|
||||
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
||||
100
|
||||
)
|
||||
const fromUserId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
const bonusTxnDetails = {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
}
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUserId,
|
||||
fromType: 'BANK',
|
||||
toId: user.id,
|
||||
toType: 'USER',
|
||||
amount: bonusAmount,
|
||||
token: 'M$',
|
||||
category: 'BETTING_STREAK_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
if (!result.txn) {
|
||||
log("betting streak bonus txn couldn't be made")
|
||||
return
|
||||
}
|
||||
|
||||
await createBettingStreakBonusNotification(
|
||||
user,
|
||||
result.txn.id,
|
||||
bet,
|
||||
contract,
|
||||
bonusAmount,
|
||||
eventId
|
||||
)
|
||||
}
|
||||
|
||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
eventId: string,
|
||||
bettorId: string
|
||||
) => {
|
||||
const userContractSnap = await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.get()
|
||||
const contract = userContractSnap.data() as Contract
|
||||
if (!contract) {
|
||||
log(`Could not find contract ${contractId}`)
|
||||
return
|
||||
}
|
||||
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||
|
||||
if (!previousUniqueBettorIds) {
|
||||
const contractBets = (
|
||||
await firestore.collection(`contracts/${contractId}/bets`).get()
|
||||
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
||||
).docs.map((doc) => doc.data() as Bet)
|
||||
|
||||
if (contractBets.length === 0) {
|
||||
log(`No bets for contract ${contractId}`)
|
||||
log(`No bets for contract ${contract.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
|
||||
await firestore.collection(`contracts`).doc(contractId).update({
|
||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
})
|
||||
|
@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
|
||||
// Create combined txn for all new unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contractId,
|
||||
contractId: contract.id,
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
}
|
||||
const fromUserId = isProd()
|
||||
|
@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
|
||||
const notifyFills = async (
|
||||
bet: Bet,
|
||||
contract: Contract,
|
||||
eventId: string,
|
||||
user: User
|
||||
) => {
|
||||
if (!bet.fills) return
|
||||
|
||||
const user = await getUser(bet.userId)
|
||||
if (!user) return
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract) return
|
||||
|
||||
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
|
||||
const matchedBets = (
|
||||
await Promise.all(
|
||||
|
@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getTodaysBettingStreakResetTime = () => {
|
||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { compact, uniq } from 'lodash'
|
||||
import { getContract, getUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { ContractComment } from '../../common/comment'
|
||||
import { sendNewCommentEmail } from './emails'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions
|
|||
contractQuestion: contract.question,
|
||||
})
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const comment = change.data() as ContractComment
|
||||
const lastCommentTime = comment.createdTime
|
||||
|
||||
const commentCreator = await getUser(comment.userId)
|
||||
|
@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions
|
|||
: undefined
|
||||
}
|
||||
|
||||
const comments = await getValues<Comment>(
|
||||
const comments = await getValues<ContractComment>(
|
||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||
)
|
||||
const relatedSourceType = comment.replyToCommentId
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { GroupComment } from '../../common/comment'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { User } from '../../common/user'
|
||||
|
@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore
|
|||
groupId: string
|
||||
}
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const comment = change.data() as GroupComment
|
||||
const creatorSnapshot = await firestore
|
||||
.collection('users')
|
||||
.doc(comment.userId)
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { User } from 'common/user'
|
||||
import { sendCreatorGuideEmail } from './emails'
|
||||
|
||||
export const onCreateContract = functions.firestore
|
||||
.document('contracts/{contractId}')
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('contracts/{contractId}')
|
||||
.onCreate(async (snapshot, context) => {
|
||||
const contract = snapshot.data() as Contract
|
||||
const { eventId } = context
|
||||
|
@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore
|
|||
richTextToString(desc),
|
||||
{ contract, recipients: mentioned }
|
||||
)
|
||||
|
||||
await sendGuideEmail(contractCreator)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const sendGuideEmail = async (contractCreator: User) => {
|
||||
const query = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', contractCreator.id)
|
||||
.limit(2)
|
||||
.get()
|
||||
|
||||
if (query.size >= 2) return
|
||||
|
||||
const privateUser = await getPrivateUser(contractCreator.id)
|
||||
if (!privateUser) return
|
||||
|
||||
await sendCreatorGuideEmail(contractCreator, privateUser)
|
||||
}
|
||||
|
|
|
@ -30,15 +30,7 @@ const bodySchema = z.object({
|
|||
|
||||
const binarySchema = z.object({
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
limitProb: z
|
||||
.number()
|
||||
.gte(0.001)
|
||||
.lte(0.999)
|
||||
.refine(
|
||||
(p) => Math.round(p * 100) === p * 100,
|
||||
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
|
||||
)
|
||||
.optional(),
|
||||
limitProb: z.number().gte(0.001).lte(0.999).optional(),
|
||||
})
|
||||
|
||||
const freeResponseSchema = z.object({
|
||||
|
@ -90,7 +82,22 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
||||
mechanism == 'cpmm-1'
|
||||
) {
|
||||
const { outcome, limitProb } = validate(binarySchema, req.body)
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { outcome, limitProb } = validate(binarySchema, req.body)
|
||||
|
||||
if (limitProb !== undefined && outcomeType === 'BINARY') {
|
||||
const isRounded = floatingEqual(
|
||||
Math.round(limitProb * 100),
|
||||
limitProb * 100
|
||||
)
|
||||
if (!isRounded)
|
||||
throw new APIError(
|
||||
400,
|
||||
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
|
||||
)
|
||||
|
||||
limitProb = Math.round(limitProb * 100) / 100
|
||||
}
|
||||
|
||||
const unfilledBetsSnap = await trans.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
|
|
38
functions/src/reset-betting-streaks.ts
Normal file
38
functions/src/reset-betting-streaks.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
|
||||
|
||||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const resetBettingStreaksForUsers = functions.pubsub
|
||||
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||
.onRun(async () => {
|
||||
await resetBettingStreaksInternal()
|
||||
})
|
||||
|
||||
const resetBettingStreaksInternal = async () => {
|
||||
const usersSnap = await firestore.collection('users').get()
|
||||
|
||||
const users = usersSnap.docs.map((doc) => doc.data() as User)
|
||||
|
||||
for (const user of users) {
|
||||
await resetBettingStreakForUser(user)
|
||||
}
|
||||
}
|
||||
|
||||
const resetBettingStreakForUser = async (user: User) => {
|
||||
const betStreakResetTime = Date.now() - DAY_MS
|
||||
// if they made a bet within the last day, don't reset their streak
|
||||
if (
|
||||
(user.lastBetTime ?? 0 > betStreakResetTime) ||
|
||||
!user.currentBettingStreak ||
|
||||
user.currentBettingStreak === 0
|
||||
)
|
||||
return
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
currentBettingStreak: 0,
|
||||
})
|
||||
}
|
31
functions/src/scripts/backfill-comment-types.ts
Normal file
31
functions/src/scripts/backfill-comment-types.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Comment types were introduced in August 2022.
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
|
||||
if (require.main === module) {
|
||||
const app = initAdmin()
|
||||
const firestore = app.firestore()
|
||||
const commentsRef = firestore.collectionGroup('comments')
|
||||
commentsRef.get().then(async (commentsSnaps) => {
|
||||
log(`Loaded ${commentsSnaps.size} comments.`)
|
||||
const needsFilling = commentsSnaps.docs.filter((ct) => {
|
||||
return !('commentType' in ct.data())
|
||||
})
|
||||
log(`Found ${needsFilling.length} comments to update.`)
|
||||
const updates = needsFilling.map((d) => {
|
||||
const comment = d.data()
|
||||
const fields: { [k: string]: unknown } = {}
|
||||
if (comment.contractId != null && comment.groupId == null) {
|
||||
fields.commentType = 'contract'
|
||||
} else if (comment.groupId != null && comment.contractId == null) {
|
||||
fields.commentType = 'group'
|
||||
} else {
|
||||
log(`Invalid comment ${comment}; not touching it.`)
|
||||
}
|
||||
return { doc: d.ref, fields, info: comment }
|
||||
})
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all comments.`)
|
||||
})
|
||||
}
|
|
@ -9,10 +9,12 @@ const initMailgun = () => {
|
|||
export const sendTextEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
text: string
|
||||
text: string,
|
||||
options?: Partial<mailgun.messages.SendData>
|
||||
) => {
|
||||
const data: mailgun.messages.SendData = {
|
||||
from: 'Manifold Markets <info@manifold.markets>',
|
||||
...options,
|
||||
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
|
|
|
@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet'
|
|||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
|
|
|
@ -5,6 +5,7 @@ module.exports = {
|
|||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@next/next/recommended',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
|
|
|
@ -3,7 +3,6 @@ import React from 'react'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { SiteLink } from './site-link'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
|
@ -37,7 +36,7 @@ export function AmountInput(props: {
|
|||
|
||||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group">
|
||||
<label className="input-group mb-4">
|
||||
<span className="bg-gray-200 text-sm">{label}</span>
|
||||
<input
|
||||
className={clsx(
|
||||
|
@ -57,8 +56,6 @@ export function AmountInput(props: {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
{error && (
|
||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||
{error === 'Insufficient balance' ? (
|
||||
|
@ -115,6 +112,8 @@ export function BuyAmountInput(props: {
|
|||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Router from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { MouseEvent, useState } from 'react'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
|
||||
export function Avatar(props: {
|
||||
|
@ -12,6 +12,7 @@ export function Avatar(props: {
|
|||
}) {
|
||||
const { username, noLink, size, className } = props
|
||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
|
||||
const onClick =
|
||||
|
|
|
@ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
|
|||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
||||
export default function BetRow(props: {
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
btnClassName?: string
|
127
web/components/bet-inline.tsx
Normal file
127
web/components/bet-inline.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
import { track } from '@amplitude/analytics-browser'
|
||||
import clsx from 'clsx'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||
import { APIError } from 'web/lib/firebase/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation } from 'react-query'
|
||||
import { placeBet } from 'web/lib/firebase/api'
|
||||
import { BuyAmountInput } from './amount-input'
|
||||
import { Button } from './button'
|
||||
import { Row } from './layout/row'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { Col } from './layout/col'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
// adapted from bet-panel.ts
|
||||
export function BetInline(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
setProbAfter: (probAfter: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { contract, className, setProbAfter, onClose } = props
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
|
||||
const [amount, setAmount] = useState<number>()
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
const { newPool, newP } = getBinaryCpmmBetInfo(
|
||||
outcome ?? 'YES',
|
||||
amount ?? 0,
|
||||
contract,
|
||||
undefined,
|
||||
unfilledBets
|
||||
)
|
||||
const resultProb = getCpmmProbability(newPool, newP)
|
||||
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
||||
|
||||
const submitBet = useMutation(
|
||||
() => placeBet({ outcome, amount, contractId: contract.id }),
|
||||
{
|
||||
onError: (e) =>
|
||||
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
|
||||
onSuccess: () => {
|
||||
track('bet', {
|
||||
location: 'embed',
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
outcome,
|
||||
isLimitOrder: false,
|
||||
})
|
||||
setAmount(undefined)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// reset error / success state on user change
|
||||
useEffect(() => {
|
||||
amount && submitBet.reset()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [outcome, amount])
|
||||
|
||||
const tooFewFunds = error === 'Insufficient balance'
|
||||
|
||||
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
|
||||
|
||||
return (
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
||||
<div className="text-xl">Bet</div>
|
||||
<YesNoSelector
|
||||
className="space-x-0"
|
||||
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
selected={outcome}
|
||||
onSelect={setOutcome}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
/>
|
||||
<BuyAmountInput
|
||||
className="-mb-4"
|
||||
inputClassName={clsx(
|
||||
'input-sm w-20 !text-base',
|
||||
error && 'input-error'
|
||||
)}
|
||||
amount={amount}
|
||||
onChange={setAmount}
|
||||
error="" // handle error ourselves
|
||||
setError={setError}
|
||||
/>
|
||||
{user && (
|
||||
<Button
|
||||
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
|
||||
size="xs"
|
||||
disabled={betDisabled}
|
||||
onClick={() => submitBet.mutate()}
|
||||
>
|
||||
{submitBet.isLoading
|
||||
? 'Submitting'
|
||||
: submitBet.isSuccess
|
||||
? 'Success!'
|
||||
: 'Submit'}
|
||||
</Button>
|
||||
)}
|
||||
<SignUpPrompt size="xs" />
|
||||
<button onClick={onClose}>
|
||||
<XIcon className="ml-1 h-6 w-6" />
|
||||
</button>
|
||||
</Row>
|
||||
{error && (
|
||||
<div className="text-error my-1 text-sm">
|
||||
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -448,8 +448,6 @@ function LimitOrderPanel(props: {
|
|||
const yesAmount = shares * (yesLimitProb ?? 1)
|
||||
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||
|
||||
const profitIfBothFilled = shares - (yesAmount + noAmount)
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
setWasSubmitted(false)
|
||||
setBetAmount(newAmount)
|
||||
|
@ -559,6 +557,8 @@ function LimitOrderPanel(props: {
|
|||
)
|
||||
const noReturnPercent = formatPercent(noReturn)
|
||||
|
||||
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
|
||||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<Row className="mt-1 items-center gap-4">
|
||||
|
|
|
@ -1,20 +1,23 @@
|
|||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
export type ColorType =
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'yellow'
|
||||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
|
||||
export function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
children?: ReactNode
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
color?:
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'yellow'
|
||||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
size?: SizeType
|
||||
color?: ColorType
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Button } from '../button'
|
||||
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { NoLabel, YesLabel } from '../outcome-label'
|
||||
|
@ -19,24 +19,32 @@ import { QRCode } from '../qr-code'
|
|||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { FIXED_ANTE } from 'common/antes'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { useTextEditor } from 'web/components/editor'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
outcome: 'YES' | 'NO' | number
|
||||
acceptorAmount: number
|
||||
question: string
|
||||
}
|
||||
|
||||
export function CreateChallengeModal(props: {
|
||||
user: User | null | undefined
|
||||
contract: BinaryContract
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
contract?: BinaryContract
|
||||
}) {
|
||||
const { user, contract, isOpen, setOpen } = props
|
||||
const [challengeSlug, setChallengeSlug] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { editor } = useTextEditor({ placeholder: '' })
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
|
@ -46,24 +54,42 @@ export function CreateChallengeModal(props: {
|
|||
<CreateChallengeForm
|
||||
user={user}
|
||||
contract={contract}
|
||||
loading={loading}
|
||||
onCreate={async (newChallenge) => {
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
message: newChallenge.message,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: contract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: contract.id,
|
||||
setLoading(true)
|
||||
try {
|
||||
const challengeContract = contract
|
||||
? contract
|
||||
: await createMarket(
|
||||
removeUndefinedProps({
|
||||
question: newChallenge.question,
|
||||
outcomeType: 'BINARY',
|
||||
initialProb: 50,
|
||||
description: editor?.getJSON(),
|
||||
ante: FIXED_ANTE,
|
||||
closeTime: dayjs().add(30, 'day').valueOf(),
|
||||
})
|
||||
)
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: challengeContract as BinaryContract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: challengeContract.id,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("couldn't create market/challenge:", e)
|
||||
}
|
||||
setLoading(false)
|
||||
}}
|
||||
challengeSlug={challengeSlug}
|
||||
/>
|
||||
|
@ -75,25 +101,24 @@ export function CreateChallengeModal(props: {
|
|||
|
||||
function CreateChallengeForm(props: {
|
||||
user: User
|
||||
contract: BinaryContract
|
||||
onCreate: (m: challengeInfo) => Promise<void>
|
||||
challengeSlug: string
|
||||
loading: boolean
|
||||
contract?: BinaryContract
|
||||
}) {
|
||||
const { user, onCreate, contract, challengeSlug } = props
|
||||
const { user, onCreate, contract, challengeSlug, loading } = props
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||
const defaultExpire = 'week'
|
||||
|
||||
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
|
||||
|
||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||
outcome: 'YES',
|
||||
amount: 100,
|
||||
acceptorAmount: 100,
|
||||
message: defaultMessage,
|
||||
question: contract ? contract.question : '',
|
||||
})
|
||||
useEffect(() => {
|
||||
setError('')
|
||||
|
@ -106,7 +131,15 @@ function CreateChallengeForm(props: {
|
|||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (user.balance < challengeInfo.amount) {
|
||||
setError('You do not have enough mana to create this challenge')
|
||||
setError("You don't have enough mana to create this challenge")
|
||||
return
|
||||
}
|
||||
if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) {
|
||||
setError(
|
||||
`You don't have enough mana to create this challenge and market. You need ${formatMoney(
|
||||
FIXED_ANTE + challengeInfo.amount
|
||||
)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
setIsCreating(true)
|
||||
|
@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
|
|||
|
||||
<div className="mb-8">
|
||||
Challenge a friend to bet on{' '}
|
||||
<span className="underline">{contract.question}</span>
|
||||
{contract ? (
|
||||
<span className="underline">{contract.question}</span>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="e.g. Will a Democrat be the next president?"
|
||||
className="input input-bordered mt-1 w-full resize-none"
|
||||
autoFocus={true}
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={challengeInfo.question}
|
||||
onChange={(e) =>
|
||||
setChallengeInfo({
|
||||
...challengeInfo,
|
||||
question: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
|
@ -187,22 +236,23 @@ function CreateChallengeForm(props: {
|
|||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
{contract && (
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
|
@ -210,7 +260,18 @@ function CreateChallengeForm(props: {
|
|||
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
||||
)}
|
||||
</span>{' '}
|
||||
in total.
|
||||
in total.{' '}
|
||||
<span>
|
||||
{!contract && (
|
||||
<span>
|
||||
Because there's no market yet, you'll be charged
|
||||
<span className={'mx-1 font-semibold'}>
|
||||
{formatMoney(FIXED_ANTE)}
|
||||
</span>
|
||||
to create it.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Row className="mt-8 items-center">
|
||||
|
@ -218,10 +279,8 @@ function CreateChallengeForm(props: {
|
|||
type="submit"
|
||||
color={'gradient'}
|
||||
size="xl"
|
||||
className={clsx(
|
||||
'whitespace-nowrap drop-shadow-md',
|
||||
isCreating ? 'disabled' : ''
|
||||
)}
|
||||
disabled={isCreating || challengeInfo.question === ''}
|
||||
className={clsx('whitespace-nowrap drop-shadow-md')}
|
||||
>
|
||||
Create challenge bet
|
||||
</Button>
|
||||
|
@ -229,7 +288,12 @@ function CreateChallengeForm(props: {
|
|||
<Row className={'text-error'}>{error} </Row>
|
||||
</form>
|
||||
)}
|
||||
{finishedCreating && (
|
||||
{loading && (
|
||||
<Col className={'h-56 w-full items-center justify-center'}>
|
||||
<LoadingIndicator />
|
||||
</Col>
|
||||
)}
|
||||
{finishedCreating && !loading && (
|
||||
<>
|
||||
<Title className="!my-0" text="Challenge Created!" />
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Comment } from 'common/comment'
|
||||
import { Comment, ContractComment } from 'common/comment'
|
||||
import { groupConsecutive } from 'common/util/array'
|
||||
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||
import { SiteLink } from './site-link'
|
||||
|
@ -16,12 +16,6 @@ import { LoadingIndicator } from './loading-indicator'
|
|||
|
||||
const COMMENTS_PER_PAGE = 50
|
||||
|
||||
type ContractComment = Comment & {
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
}
|
||||
|
||||
function contractPath(slug: string) {
|
||||
// by convention this includes the contract creator username, but we don't
|
||||
// have that handy, so we just put /market/
|
||||
|
@ -38,7 +32,9 @@ export function UserCommentsList(props: { user: User }) {
|
|||
useEffect(() => {
|
||||
getUsersComments(user.id).then((cs) => {
|
||||
// we don't show comments in groups here atm, just comments on contracts
|
||||
setComments(cs.filter((c) => c.contractId) as ContractComment[])
|
||||
setComments(
|
||||
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
|
||||
)
|
||||
})
|
||||
}, [user.id])
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ export function ContractSearch(props: {
|
|||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
hideOrderSelector?: boolean
|
||||
gridClassName?: string
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
|
@ -91,6 +91,7 @@ export function ContractSearch(props: {
|
|||
headerClassName?: string
|
||||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
isWholePage?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -98,13 +99,14 @@ export function ContractSearch(props: {
|
|||
defaultFilter,
|
||||
additionalFilter,
|
||||
onContractClick,
|
||||
gridClassName,
|
||||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
headerClassName,
|
||||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
isWholePage,
|
||||
} = props
|
||||
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
|
@ -139,7 +141,7 @@ export function ContractSearch(props: {
|
|||
setNumPages(results.nbPages)
|
||||
if (freshQuery) {
|
||||
setPages([newPage])
|
||||
window.scrollTo(0, 0)
|
||||
if (isWholePage) window.scrollTo(0, 0)
|
||||
} else {
|
||||
setPages((pages) => [...pages, newPage])
|
||||
}
|
||||
|
@ -181,7 +183,7 @@ export function ContractSearch(props: {
|
|||
loadMore={performQuery}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
gridClassName={gridClassName}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
|
|
|
@ -122,19 +122,6 @@ export function ContractCard(props: {
|
|||
) : (
|
||||
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
||||
))}
|
||||
<Row className={'absolute bottom-3 gap-2 md:gap-0'}>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
short={true}
|
||||
className={'block md:hidden'}
|
||||
/>
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
<QuickBet contract={contract} user={user} />
|
||||
|
@ -172,6 +159,24 @@ export function ContractCard(props: {
|
|||
<ProbBar contract={contract} />
|
||||
</>
|
||||
)}
|
||||
<Row
|
||||
className={clsx(
|
||||
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
|
||||
showQuickBet ? 'w-[85%]' : 'w-full'
|
||||
)}
|
||||
>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
short={true}
|
||||
className={'block md:hidden'}
|
||||
/>
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -180,11 +185,16 @@ export function BinaryResolutionOrChance(props: {
|
|||
contract: BinaryContract
|
||||
large?: boolean
|
||||
className?: string
|
||||
probAfter?: number // 0 to 1
|
||||
}) {
|
||||
const { contract, large, className } = props
|
||||
const { contract, large, className, probAfter } = props
|
||||
const { resolution } = contract
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
const before = getBinaryProbPercent(contract)
|
||||
const after = probAfter && formatPercent(probAfter)
|
||||
const probChanged = before !== after
|
||||
|
||||
return (
|
||||
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
||||
{resolution ? (
|
||||
|
@ -201,7 +211,14 @@ export function BinaryResolutionOrChance(props: {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
|
||||
{probAfter && probChanged ? (
|
||||
<div>
|
||||
<span className="text-gray-500 line-through">{before}</span>
|
||||
<span className={textColor}>{after}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={textColor}>{before}</div>
|
||||
)}
|
||||
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
|
||||
chance
|
||||
</div>
|
||||
|
|
|
@ -55,13 +55,13 @@ export function MiscDetails(props: {
|
|||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-3 text-sm text-gray-400">
|
||||
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showTime === 'close-date' ? (
|
||||
<Row className="gap-0.5">
|
||||
<Row className="gap-0.5 whitespace-nowrap">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
|
|
|
@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
<tr>
|
||||
<td>Payout</td>
|
||||
<td>
|
||||
<td className="flex gap-1">
|
||||
{mechanism === 'cpmm-1' ? (
|
||||
<>
|
||||
Fixed{' '}
|
||||
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<>
|
||||
Parimutuel{' '}
|
||||
<InfoTooltip text="Each share is a fraction of the pool. " />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
@ -65,7 +65,7 @@ export function ContractLeaderboard(props: {
|
|||
export function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
import { Bet } from 'common/bet'
|
||||
import BetRow from '../bet-row'
|
||||
import BetButton from '../bet-button'
|
||||
import { AnswersGraph } from '../answers/answers-graph'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractDescription } from './contract-description'
|
||||
|
@ -73,18 +73,18 @@ export const ContractOverview = (props: {
|
|||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow contract={contract as CPMMBinaryContract} />
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { ContractActivity } from '../feed/contract-activity'
|
||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||
|
@ -15,7 +15,7 @@ export function ContractTabs(props: {
|
|||
contract: Contract
|
||||
user: User | null | undefined
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, tips } = props
|
||||
|
|
|
@ -20,7 +20,7 @@ export function ContractsGrid(props: {
|
|||
loadMore?: () => void
|
||||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
gridClassName?: string
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
|
@ -32,7 +32,7 @@ export function ContractsGrid(props: {
|
|||
showTime,
|
||||
loadMore,
|
||||
onContractClick,
|
||||
gridClassName,
|
||||
overrideGridClassName,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
|
@ -66,8 +66,9 @@ export function ContractsGrid(props: {
|
|||
<Col className="gap-8">
|
||||
<ul
|
||||
className={clsx(
|
||||
'w-full columns-1 gap-4 space-y-4 md:columns-2',
|
||||
gridClassName
|
||||
overrideGridClassName
|
||||
? overrideGridClassName
|
||||
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{contracts.map((contract) => (
|
||||
|
@ -80,10 +81,11 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
className={clsx(
|
||||
'break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
)}
|
||||
className={
|
||||
contractIds?.includes(contract.id)
|
||||
? highlightClassName
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -65,7 +65,9 @@ export function MarketModal(props: {
|
|||
<ContractSearch
|
||||
hideOrderSelector
|
||||
onContractClick={addContract}
|
||||
gridClassName="gap-3 space-y-3"
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
highlightOptions={{
|
||||
contractIds: contracts.map((c) => c.id),
|
||||
|
|
|
@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
|||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)}
|
||||
onClick={() => submitUser(i)}
|
||||
key={user.id}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||
{user.username}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash'
|
|||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
import { getOutcomeProbability } from 'common/calculate'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
|
@ -28,7 +28,7 @@ type BaseActivityItem = {
|
|||
export type CommentInputItem = BaseActivityItem & {
|
||||
type: 'commentInput'
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: Comment[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
}
|
||||
|
||||
export type DescriptionItem = BaseActivityItem & {
|
||||
|
@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & {
|
|||
|
||||
export type CommentThreadItem = BaseActivityItem & {
|
||||
type: 'commentThread'
|
||||
parentComment: Comment
|
||||
comments: Comment[]
|
||||
parentComment: ContractComment
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
|
|||
type: 'answergroup'
|
||||
user: User | undefined | null
|
||||
answer: Answer
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & {
|
|||
function getAnswerAndCommentInputGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
tips: CommentTipMap,
|
||||
user: User | undefined | null
|
||||
) {
|
||||
|
@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups(
|
|||
|
||||
function getCommentThreads(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
tips: CommentTipMap,
|
||||
contract: Contract
|
||||
) {
|
||||
|
@ -135,7 +135,7 @@ function getCommentThreads(
|
|||
return items
|
||||
}
|
||||
|
||||
function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
||||
function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
|
||||
return (
|
||||
comment.answerOutcome === undefined &&
|
||||
(contract.outcomeType === 'FREE_RESPONSE'
|
||||
|
@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
|||
export function getSpecificContractActivityItems(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
comments: ContractComment[],
|
||||
liquidityProvisions: LiquidityProvision[],
|
||||
tips: CommentTipMap,
|
||||
user: User | null | undefined,
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import { union, difference } from 'lodash'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Col } from '../layout/col'
|
||||
import { useState } from 'react'
|
||||
import { updateUser, User } from 'web/lib/firebase/users'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function CategorySelector(props: {
|
||||
category: string
|
||||
setCategory: (category: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { className, category, setCategory } = props
|
||||
|
||||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<CategoryButton
|
||||
key="all"
|
||||
category="All"
|
||||
isFollowed={category === 'all'}
|
||||
toggle={() => {
|
||||
setCategory('all')
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategoryButton
|
||||
key="following"
|
||||
category="Following"
|
||||
isFollowed={category === 'following'}
|
||||
toggle={() => {
|
||||
setCategory('following')
|
||||
}}
|
||||
/>
|
||||
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<CategoryButton
|
||||
key={cat}
|
||||
category={CATEGORIES[cat as category].split(' ')[0]}
|
||||
isFollowed={cat === category}
|
||||
toggle={() => {
|
||||
setCategory(cat)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryButton(props: {
|
||||
category: string
|
||||
isFollowed: boolean
|
||||
toggle: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const { toggle, category, isFollowed, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
||||
'cursor-pointer select-none',
|
||||
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
||||
)}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="text-sm text-gray-500">{category}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditCategoriesButton(props: {
|
||||
user: User
|
||||
className?: string
|
||||
}) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
track('edit categories button')
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Categories
|
||||
<CategorySelectorModal
|
||||
user={user}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategorySelectorModal(props: {
|
||||
user: User
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}) {
|
||||
const { user, isOpen, setIsOpen } = props
|
||||
const followedCategories =
|
||||
user?.followedCategories === undefined
|
||||
? CATEGORY_LIST
|
||||
: user.followedCategories
|
||||
|
||||
const selectAll =
|
||||
user.followedCategories === undefined ||
|
||||
followedCategories.length < CATEGORY_LIST.length
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
<Col className="rounded bg-white p-6">
|
||||
<button
|
||||
className="btn btn-sm btn-outline mb-4 self-start normal-case"
|
||||
onClick={() => {
|
||||
if (selectAll) {
|
||||
updateUser(user.id, {
|
||||
followedCategories: CATEGORY_LIST,
|
||||
})
|
||||
} else {
|
||||
updateUser(user.id, {
|
||||
followedCategories: [],
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select {selectAll ? 'all' : 'none'}
|
||||
</button>
|
||||
<Col className="grid w-full grid-cols-2 gap-4">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Checkbox
|
||||
className="col-span-1"
|
||||
key={cat}
|
||||
label={CATEGORIES[cat as category].split(' ')[0]}
|
||||
checked={followedCategories.includes(cat)}
|
||||
toggle={(checked) => {
|
||||
updateUser(user.id, {
|
||||
followedCategories: checked
|
||||
? difference(followedCategories, [cat])
|
||||
: union([cat], followedCategories),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Bet } from 'common/bet'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { getSpecificContractActivityItems } from './activity-items'
|
||||
|
@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision'
|
|||
export function ContractActivity(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
contract: any
|
||||
user: User | undefined | null
|
||||
answer: Answer
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
bets: Bet[]
|
||||
}) {
|
||||
|
@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
])
|
||||
|
||||
const scrollAndOpenReplyInput = useEvent(
|
||||
(comment?: Comment, answer?: Answer) => {
|
||||
(comment?: ContractComment, answer?: Answer) => {
|
||||
setReplyToUser(
|
||||
comment
|
||||
? { id: comment.userId, username: comment.userUsername }
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react'
|
|||
|
||||
export function FeedCommentThread(props: {
|
||||
contract: Contract
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
parentComment: Comment
|
||||
parentComment: ContractComment
|
||||
bets: Bet[]
|
||||
smallAvatar?: boolean
|
||||
}) {
|
||||
|
@ -50,7 +50,7 @@ export function FeedCommentThread(props: {
|
|||
)
|
||||
commentsList.unshift(parentComment)
|
||||
|
||||
function scrollAndOpenReplyInput(comment: Comment) {
|
||||
function scrollAndOpenReplyInput(comment: ContractComment) {
|
||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||
setShowReply(true)
|
||||
}
|
||||
|
@ -95,10 +95,10 @@ export function FeedCommentThread(props: {
|
|||
|
||||
export function CommentRepliesList(props: {
|
||||
contract: Contract
|
||||
commentsList: Comment[]
|
||||
commentsList: ContractComment[]
|
||||
betsByUserId: Dictionary<Bet[]>
|
||||
tips: CommentTipMap
|
||||
scrollAndOpenReplyInput: (comment: Comment) => void
|
||||
scrollAndOpenReplyInput: (comment: ContractComment) => void
|
||||
bets: Bet[]
|
||||
treatFirstIndexEqually?: boolean
|
||||
smallAvatar?: boolean
|
||||
|
@ -156,12 +156,12 @@ export function CommentRepliesList(props: {
|
|||
|
||||
export function FeedComment(props: {
|
||||
contract: Contract
|
||||
comment: Comment
|
||||
comment: ContractComment
|
||||
tips: CommentTips
|
||||
betsBySameUser: Bet[]
|
||||
probAtCreatedTime?: number
|
||||
smallAvatar?: boolean
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
onReplyClick?: (comment: ContractComment) => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
|
@ -274,7 +274,7 @@ export function FeedComment(props: {
|
|||
|
||||
export function getMostRecentCommentableBet(
|
||||
betsByCurrentUser: Bet[],
|
||||
commentsByCurrentUser: Comment[],
|
||||
commentsByCurrentUser: ContractComment[],
|
||||
user?: User | null,
|
||||
answerOutcome?: string
|
||||
) {
|
||||
|
@ -319,7 +319,7 @@ function CommentStatus(props: {
|
|||
export function CommentInput(props: {
|
||||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: Comment[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
|
|
|
@ -18,7 +18,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
|
|||
import { SiteLink } from '../site-link'
|
||||
import { Col } from '../layout/col'
|
||||
import { UserLink } from '../user-page'
|
||||
import BetRow from '../bet-row'
|
||||
import BetButton from '../bet-button'
|
||||
import { Avatar } from '../avatar'
|
||||
import { ActivityItem } from './activity-items'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -76,7 +76,7 @@ export function FeedItems(props: {
|
|||
) : (
|
||||
outcomeType === 'BINARY' &&
|
||||
tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
<BetButton
|
||||
contract={contract as CPMMBinaryContract}
|
||||
className={clsx('mb-2', betRowClassName)}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Bet } from 'common/bet'
|
||||
|
||||
const MAX_ACTIVE_CONTRACTS = 75
|
||||
|
@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) {
|
|||
// - Bet on market
|
||||
export function findActiveContracts(
|
||||
allContracts: Contract[],
|
||||
recentComments: Comment[],
|
||||
recentComments: ContractComment[],
|
||||
recentBets: Bet[],
|
||||
seenContracts: { [contractId: string]: number }
|
||||
) {
|
||||
|
@ -73,7 +73,7 @@ export function findActiveContracts(
|
|||
)
|
||||
const contractMostRecentComment = mapValues(
|
||||
contractComments,
|
||||
(comments) => maxBy(comments, (c) => c.createdTime) as Comment
|
||||
(comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
|
||||
)
|
||||
|
||||
const prioritizedContracts = sortBy(activeContracts, (c) => {
|
||||
|
|
|
@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user'
|
|||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Group } from 'common/group'
|
||||
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||
import { Comment, GroupComment } from 'common/comment'
|
||||
import { createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
|
|||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
|
||||
export function GroupChat(props: {
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
user: User | null | undefined
|
||||
group: Group
|
||||
tips: CommentTipMap
|
||||
|
@ -58,7 +59,7 @@ export function GroupChat(props: {
|
|||
// array of groups, where each group is an array of messages that are displayed as one
|
||||
const groupedMessages = useMemo(() => {
|
||||
// Group messages with createdTime within 2 minutes of each other.
|
||||
const tempGrouped: Comment[][] = []
|
||||
const tempGrouped: GroupComment[][] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (i === 0) tempGrouped.push([message])
|
||||
|
@ -193,7 +194,7 @@ export function GroupChat(props: {
|
|||
}
|
||||
|
||||
export function GroupChatInBubble(props: {
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
user: User | null | undefined
|
||||
privateUser: PrivateUser | null | undefined
|
||||
group: Group
|
||||
|
@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: {
|
|||
|
||||
const GroupMessage = memo(function GroupMessage_(props: {
|
||||
user: User | null | undefined
|
||||
comments: Comment[]
|
||||
comments: GroupComment[]
|
||||
group: Group
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
setRef?: (ref: HTMLDivElement) => void
|
||||
|
@ -353,7 +354,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
|||
elementId={id}
|
||||
/>
|
||||
</Row>
|
||||
<div className="mt-2 text-black">
|
||||
<div className="mt-2 text-base text-black">
|
||||
{comments.map((comment) => (
|
||||
<Content
|
||||
key={comment.id}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
|
|||
import { NoLabel, YesLabel } from './outcome-label'
|
||||
import { Col } from './layout/col'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
|
@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="align-center mb-4 text-gray-500">
|
||||
Subsidize this market by adding M$ to the liquidity pool.
|
||||
<div className="mb-4 text-gray-500">
|
||||
Contribute your M$ to make this market more accurate.{' '}
|
||||
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
|
|
|
@ -33,7 +33,7 @@ function getNavigation() {
|
|||
|
||||
const signedOutNavigation = [
|
||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
]
|
||||
|
||||
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
||||
|
|
|
@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
const signedOutNavigation = [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
|
|
32
web/components/profile/betting-streak-modal.tsx
Normal file
32
web/components/profile/betting-streak-modal.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
||||
export function BettingStreakModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { isOpen, setOpen } = props
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🔥</span>
|
||||
<span>Betting streaks are here!</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are they?</span>
|
||||
<span className={'ml-2'}>
|
||||
You get a reward for every consecutive day that you place a bet. The
|
||||
more days you bet in a row, the more you earn!
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• Where can I check my streak?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
You can see your current streak on the top right of your profile
|
||||
page.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -2,17 +2,21 @@ import React from 'react'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Button } from './button'
|
||||
import { Button, SizeType } from './button'
|
||||
|
||||
export function SignUpPrompt(props: { label?: string; className?: string }) {
|
||||
const { label, className } = props
|
||||
export function SignUpPrompt(props: {
|
||||
label?: string
|
||||
className?: string
|
||||
size?: SizeType
|
||||
}) {
|
||||
const { label, className, size = 'lg' } = props
|
||||
const user = useUser()
|
||||
|
||||
return user === null ? (
|
||||
<Button
|
||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||
className={className}
|
||||
size="lg"
|
||||
size={size}
|
||||
color="gradient"
|
||||
>
|
||||
{label ?? 'Sign up to bet!'}
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { parseWordsAsTags } from 'common/util/parse'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { TagsList } from './tags-list'
|
||||
import { MAX_TAG_LENGTH } from 'common/contract'
|
||||
|
||||
export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||
const { contract, className } = props
|
||||
const { tags } = contract
|
||||
|
||||
const [tagText, setTagText] = useState('')
|
||||
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const updateTags = async () => {
|
||||
setIsSubmitting(true)
|
||||
await updateContract(contract.id, {
|
||||
tags: newTags,
|
||||
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
setTagText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={clsx('gap-4', className)}>
|
||||
<TagsList tags={newTags} noLabel />
|
||||
|
||||
<Row className="items-center gap-4">
|
||||
<input
|
||||
style={{ maxWidth: 150 }}
|
||||
placeholder="Type a tag..."
|
||||
className="input input-sm input-bordered resize-none"
|
||||
disabled={isSubmitting}
|
||||
value={tagText}
|
||||
maxLength={MAX_TAG_LENGTH}
|
||||
onChange={(e) => setTagText(e.target.value || '')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
updateTags()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-xs btn-outline" onClick={updateTags}>
|
||||
Save tags
|
||||
</button>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { CATEGORIES, category } from '../../common/categories'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
import { Row } from './layout/row'
|
||||
import { SiteLink } from './site-link'
|
||||
|
||||
function Hashtag(props: { tag: string; noLink?: boolean }) {
|
||||
const { tag, noLink } = props
|
||||
const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category]
|
||||
|
||||
const body = (
|
||||
<div className={clsx('', !noLink && 'cursor-pointer')}>
|
||||
<span className="text-sm">{category ? '#' + category : tag} </span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (noLink) return body
|
||||
return (
|
||||
<SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center">
|
||||
{body}
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function TagsList(props: {
|
||||
tags: string[]
|
||||
className?: string
|
||||
noLink?: boolean
|
||||
noLabel?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const { tags, className, noLink, noLabel, label } = props
|
||||
return (
|
||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
||||
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
|
||||
{tags.map((tag) => (
|
||||
<Hashtag
|
||||
key={tag}
|
||||
tag={tag.startsWith('#') ? tag : `#${tag}`}
|
||||
noLink={noLink}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function FoldTag(props: { fold: { slug: string; name: string } }) {
|
||||
const { fold } = props
|
||||
const { slug, name } = fold
|
||||
|
||||
return (
|
||||
<SiteLink href={`/fold/${slug}`} className="flex items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full border-2 bg-white px-4 py-1 shadow-md',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm text-gray-500">{name}</span>
|
||||
</div>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function FoldTagList(props: {
|
||||
folds: { slug: string; name: string }[]
|
||||
noLabel?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { folds, noLabel, className } = props
|
||||
return (
|
||||
<Col className="gap-2">
|
||||
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
|
||||
<Row className={clsx('flex-wrap items-center gap-2', className)}>
|
||||
{folds.length > 0 && (
|
||||
<>
|
||||
{folds.map((fold) => (
|
||||
<FoldTag key={fold.slug} fold={fold} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
return
|
||||
}
|
||||
|
||||
const contractId =
|
||||
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||
const groupId =
|
||||
comment.commentType === 'group' ? comment.groupId : undefined
|
||||
await transact({
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
|
@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
toType: 'USER',
|
||||
token: 'M$',
|
||||
category: 'TIP',
|
||||
data: {
|
||||
contractId: comment.contractId,
|
||||
commentId: comment.id,
|
||||
groupId: comment.groupId,
|
||||
},
|
||||
data: { commentId: comment.id, contractId, groupId },
|
||||
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
||||
})
|
||||
|
||||
track('send comment tip', {
|
||||
contractId: comment.contractId,
|
||||
commentId: comment.id,
|
||||
groupId: comment.groupId,
|
||||
contractId,
|
||||
groupId,
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
toId: comment.userId,
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
Placement,
|
||||
shift,
|
||||
useFloating,
|
||||
useFocus,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useRole,
|
||||
|
@ -48,7 +47,6 @@ export function Tooltip(props: {
|
|||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
useHover(context, { mouseOnly: noTap }),
|
||||
useFocus(context),
|
||||
useRole(context, { role: 'tooltip' }),
|
||||
])
|
||||
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
||||
|
@ -64,7 +62,6 @@ export function Tooltip(props: {
|
|||
<div
|
||||
className={clsx('inline-block', className)}
|
||||
ref={reference}
|
||||
tabIndex={noTap ? undefined : 0}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -58,8 +59,6 @@ export function UserLink(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
||||
|
||||
export function UserPage(props: { user: User }) {
|
||||
const { user } = props
|
||||
const router = useRouter()
|
||||
|
@ -67,10 +66,13 @@ export function UserPage(props: { user: User }) {
|
|||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
setShowConfetti(claimedMana)
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
setShowBettingStreakModal(showBettingStreak)
|
||||
}, [router])
|
||||
|
||||
const profit = user.profitCached.allTime
|
||||
|
@ -82,9 +84,14 @@ export function UserPage(props: { user: User }) {
|
|||
description={user.bio ?? ''}
|
||||
url={`/${user.username}`}
|
||||
/>
|
||||
{showConfetti && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
)}
|
||||
{showConfetti ||
|
||||
(showBettingStreakModal && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
))}
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
/>
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
|
@ -103,10 +110,10 @@ export function UserPage(props: { user: User }) {
|
|||
</div>
|
||||
|
||||
{/* Top right buttons (e.g. edit, follow) */}
|
||||
<div className="absolute right-0 top-0 mt-4 mr-4">
|
||||
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="btn" href="/profile">
|
||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
|
@ -116,19 +123,34 @@ export function UserPage(props: { user: User }) {
|
|||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<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>
|
||||
<Row className={'justify-between'}>
|
||||
<Col>
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
<Row className={'gap-3'}>
|
||||
<Col className={'items-center text-gray-500'}>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>
|
||||
<span>profit</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={'cursor-pointer items-center text-gray-500'}
|
||||
onClick={() => setShowBettingStreakModal(true)}
|
||||
>
|
||||
<span>🔥{user.currentBettingStreak ?? 0}</span>
|
||||
<span>streak</span>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={4} />
|
||||
{user.bio && (
|
||||
<>
|
||||
|
@ -138,17 +160,7 @@ export function UserPage(props: { user: User }) {
|
|||
<Spacer h={4} />
|
||||
</>
|
||||
)}
|
||||
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<Row className="gap-4">
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
{currentUser &&
|
||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||
currentUser.username
|
||||
) && <ReferralsButton user={user} />}
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
|
||||
<Row className="flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
|
@ -198,7 +210,7 @@ export function UserPage(props: { user: User }) {
|
|||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={5} />
|
||||
{currentUser?.id === user.id && (
|
||||
<Row
|
||||
|
@ -208,7 +220,7 @@ export function UserPage(props: { user: User }) {
|
|||
>
|
||||
<span>
|
||||
<SiteLink href="/referrals">
|
||||
Refer a friend and earn {formatMoney(500)} when they sign up!
|
||||
Earn {formatMoney(500)} when you refer a friend!
|
||||
</SiteLink>{' '}
|
||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||
</span>
|
||||
|
@ -244,6 +256,22 @@ export function UserPage(props: { user: User }) {
|
|||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Social',
|
||||
content: (
|
||||
<Row
|
||||
className={'mt-2 flex-wrap items-center justify-center gap-6'}
|
||||
>
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
{currentUser &&
|
||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||
currentUser.username
|
||||
) && <ReferralsButton user={user} />}
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
@ -38,7 +38,7 @@ export function YesNoSelector(props: {
|
|||
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
||||
selected == 'YES'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-primary bg-transparent',
|
||||
: 'text-primary bg-white',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => onSelect('YES')}
|
||||
|
@ -55,7 +55,7 @@ export function YesNoSelector(props: {
|
|||
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
|
||||
selected == 'NO'
|
||||
? 'bg-red-400 text-white'
|
||||
: 'bg-transparent text-red-400',
|
||||
: 'bg-white text-red-400',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => onSelect('NO')}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { feed } from 'common/feed'
|
||||
import { useTimeSinceFirstRender } from './use-time-since-first-render'
|
||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
||||
import { User } from 'common/user'
|
||||
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
|
||||
import {
|
||||
getRecentBetsAndComments,
|
||||
getTopWeeklyContracts,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useAlgoFeed = (
|
||||
user: User | null | undefined,
|
||||
category: string
|
||||
) => {
|
||||
const [allFeed, setAllFeed] = useState<feed>()
|
||||
const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>()
|
||||
|
||||
const getTime = useTimeSinceFirstRender()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getUserFeed(user.id).then((feed) => {
|
||||
if (feed.length === 0) {
|
||||
getDefaultFeed().then((feed) => setAllFeed(feed))
|
||||
} else setAllFeed(feed)
|
||||
|
||||
trackLatency(user.id, 'feed', getTime())
|
||||
console.log('"all" feed load time', getTime())
|
||||
})
|
||||
|
||||
getCategoryFeeds(user.id).then((feeds) => {
|
||||
setCategoryFeeds(feeds)
|
||||
console.log('category feeds load time', getTime())
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id])
|
||||
|
||||
const feed = category === 'all' ? allFeed : categoryFeeds?.[category]
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
const getDefaultFeed = async () => {
|
||||
const contracts = await getTopWeeklyContracts()
|
||||
const feed = await Promise.all(
|
||||
contracts.map((c) => getRecentBetsAndComments(c))
|
||||
)
|
||||
return feed
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import {
|
||||
Comment,
|
||||
listenForCommentsOnContract,
|
||||
listenForCommentsOnGroup,
|
||||
listenForRecentComments,
|
||||
} from 'web/lib/firebase/comments'
|
||||
|
||||
export const useComments = (contractId: string) => {
|
||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForCommentsOnContract(contractId, setComments)
|
||||
|
@ -16,7 +16,7 @@ export const useComments = (contractId: string) => {
|
|||
return comments
|
||||
}
|
||||
export const useCommentsOnGroup = (groupId: string | undefined) => {
|
||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||
const [comments, setComments] = useState<GroupComment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
|
||||
|
|
|
@ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType === 'bonus' || notification.sourceType === 'tip'
|
||||
notification.sourceType === 'bonus' ||
|
||||
notification.sourceType === 'tip' ||
|
||||
notification.sourceType === 'betting_streak_bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
|
||||
notification.sourceType !== 'bonus' &&
|
||||
notification.sourceType !== 'tip' &&
|
||||
notification.sourceType !== 'betting_streak_bonus'
|
||||
)
|
||||
if (incomeNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
|
|
|
@ -29,13 +29,11 @@ export async function createChallenge(data: {
|
|||
creatorAmount: number
|
||||
acceptorAmount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
}) {
|
||||
const {
|
||||
creator,
|
||||
creatorAmount,
|
||||
expiresTime,
|
||||
message,
|
||||
contract,
|
||||
outcome,
|
||||
acceptorAmount,
|
||||
|
@ -73,7 +71,7 @@ export async function createChallenge(data: {
|
|||
acceptedByUserIds: [],
|
||||
acceptances: [],
|
||||
isResolved: false,
|
||||
message,
|
||||
message: '',
|
||||
}
|
||||
|
||||
await setDoc(doc(challenges(contract.id), slug), challenge)
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { User } from 'common/user'
|
||||
import { Comment } from 'common/comment'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
|
@ -31,8 +31,10 @@ export async function createCommentOnContract(
|
|||
const ref = betId
|
||||
? doc(getCommentsCollection(contractId), betId)
|
||||
: doc(getCommentsCollection(contractId))
|
||||
const comment: Comment = removeUndefinedProps({
|
||||
// contract slug and question are set via trigger
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
commentType: 'contract',
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
content: content,
|
||||
|
@ -59,8 +61,9 @@ export async function createCommentOnGroup(
|
|||
replyToCommentId?: string
|
||||
) {
|
||||
const ref = doc(getCommentsOnGroupCollection(groupId))
|
||||
const comment: Comment = removeUndefinedProps({
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
commentType: 'group',
|
||||
groupId,
|
||||
userId: user.id,
|
||||
content: content,
|
||||
|
@ -94,7 +97,7 @@ export async function listAllComments(contractId: string) {
|
|||
}
|
||||
|
||||
export async function listAllCommentsOnGroup(groupId: string) {
|
||||
const comments = await getValues<Comment>(
|
||||
const comments = await getValues<GroupComment>(
|
||||
getCommentsOnGroupCollection(groupId)
|
||||
)
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -103,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) {
|
|||
|
||||
export function listenForCommentsOnContract(
|
||||
contractId: string,
|
||||
setComments: (comments: Comment[]) => void
|
||||
setComments: (comments: ContractComment[]) => void
|
||||
) {
|
||||
return listenForValues<Comment>(
|
||||
return listenForValues<ContractComment>(
|
||||
getCommentsCollection(contractId),
|
||||
(comments) => {
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -115,9 +118,9 @@ export function listenForCommentsOnContract(
|
|||
}
|
||||
export function listenForCommentsOnGroup(
|
||||
groupId: string,
|
||||
setComments: (comments: Comment[]) => void
|
||||
setComments: (comments: GroupComment[]) => void
|
||||
) {
|
||||
return listenForValues<Comment>(
|
||||
return listenForValues<GroupComment>(
|
||||
getCommentsOnGroupCollection(groupId),
|
||||
(comments) => {
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
|
|
@ -19,7 +19,6 @@ import { BinaryContract, Contract } from 'common/contract'
|
|||
import { createRNG, shuffle } from 'common/util/random'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
@ -258,16 +257,6 @@ export async function getContractsBySlugs(slugs: string[]) {
|
|||
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||
}
|
||||
|
||||
const topWeeklyQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
orderBy('volume7Days', 'desc'),
|
||||
limit(MAX_FEED_CONTRACTS)
|
||||
)
|
||||
export async function getTopWeeklyContracts() {
|
||||
return await getValues<Contract>(topWeeklyQuery)
|
||||
}
|
||||
|
||||
const closingSoonQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
|
|
|
@ -14,20 +14,10 @@ import {
|
|||
onSnapshot,
|
||||
} from 'firebase/firestore'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
||||
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
||||
import { zip } from 'lodash'
|
||||
import { app, db } from './init'
|
||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||
import {
|
||||
coll,
|
||||
getValue,
|
||||
getValues,
|
||||
listenForValue,
|
||||
listenForValues,
|
||||
} from './utils'
|
||||
import { feed } from 'common/feed'
|
||||
import { CATEGORY_LIST } from 'common/categories'
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { safeLocalStorage } from '../util/local'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
|
||||
|
@ -202,20 +192,6 @@ export async function firebaseLogout() {
|
|||
await auth.signOut()
|
||||
}
|
||||
|
||||
const storage = getStorage(app)
|
||||
// Example: uploadData('avatars/ajfi8iejsf.png', data)
|
||||
export async function uploadData(
|
||||
path: string,
|
||||
data: ArrayBuffer | Blob | Uint8Array
|
||||
) {
|
||||
const uploadRef = ref(storage, path)
|
||||
// Uploaded files should be cached for 1 day, then revalidated
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||
const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' }
|
||||
await uploadBytes(uploadRef, data, metadata)
|
||||
return await getDownloadURL(uploadRef)
|
||||
}
|
||||
|
||||
export async function listUsers(userIds: string[]) {
|
||||
if (userIds.length > 10) {
|
||||
throw new Error('Too many users requested at once; Firestore limits to 10')
|
||||
|
@ -263,25 +239,6 @@ export function getUsers() {
|
|||
return getValues<User>(users)
|
||||
}
|
||||
|
||||
export async function getUserFeed(userId: string) {
|
||||
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
|
||||
const userFeed = await getValue<{
|
||||
feed: feed
|
||||
}>(feedDoc)
|
||||
return userFeed?.feed ?? []
|
||||
}
|
||||
|
||||
export async function getCategoryFeeds(userId: string) {
|
||||
const cacheCollection = collection(privateUsers, userId, 'cache')
|
||||
const feedData = await Promise.all(
|
||||
CATEGORY_LIST.map((category) =>
|
||||
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
||||
)
|
||||
)
|
||||
const feeds = feedData.map((data) => data?.feed ?? [])
|
||||
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][])
|
||||
}
|
||||
|
||||
export async function follow(userId: string, followedUserId: string) {
|
||||
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
||||
await setDoc(followDoc, {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
const API_DOCS_URL = 'https://docs.manifold.markets/api'
|
||||
|
||||
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
staticPageGenerationTimeout: 600, // e.g. stats page
|
||||
|
@ -35,6 +37,11 @@ module.exports = {
|
|||
destination: API_DOCS_URL,
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/about',
|
||||
destination: ABOUT_PAGE_URL,
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/analytics',
|
||||
destination: '/stats',
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"next-sitemap": "^2.5.14",
|
||||
"postcss": "8.3.5",
|
||||
"prettier-plugin-tailwindcss": "^0.1.5",
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
||||
import { Comment, listAllComments } from 'web/lib/firebase/comments'
|
||||
import { listAllComments } from 'web/lib/firebase/comments'
|
||||
import Custom404 from '../404'
|
||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
|
@ -37,6 +37,7 @@ import { useTracking } from 'web/hooks/use-tracking'
|
|||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { User } from 'common/user'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { listUsers } from 'web/lib/firebase/users'
|
||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||
import { Title } from 'web/components/title'
|
||||
|
@ -78,7 +79,7 @@ export default function ContractPage(props: {
|
|||
contract: Contract | null
|
||||
username: string
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
slug: string
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
|
@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
|||
function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
|
|
|
@ -7,6 +7,7 @@ import { User } from 'common/user'
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
|
||||
export type LiteMarket = {
|
||||
// Unique identifer for this market
|
||||
|
@ -22,6 +23,7 @@ export type LiteMarket = {
|
|||
closeTime?: number
|
||||
question: string
|
||||
description: string | JSONContent
|
||||
textDescription: string // string version of description
|
||||
tags: string[]
|
||||
url: string
|
||||
outcomeType: string
|
||||
|
@ -40,6 +42,8 @@ export type LiteMarket = {
|
|||
resolution?: string
|
||||
resolutionTime?: number
|
||||
resolutionProbability?: number
|
||||
|
||||
lastUpdatedTime?: number
|
||||
}
|
||||
|
||||
export type ApiAnswer = Answer & {
|
||||
|
@ -90,6 +94,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
lastUpdatedTime,
|
||||
} = contract
|
||||
|
||||
const { p, totalLiquidity } = contract as any
|
||||
|
@ -97,6 +102,11 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
const probability =
|
||||
contract.outcomeType === 'BINARY' ? getProbability(contract) : undefined
|
||||
|
||||
let min, max, isLogScale: any
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
;({ min, max, isLogScale } = contract)
|
||||
}
|
||||
|
||||
return removeUndefinedProps({
|
||||
id,
|
||||
creatorUsername,
|
||||
|
@ -109,6 +119,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
: closeTime,
|
||||
question,
|
||||
description,
|
||||
textDescription:
|
||||
typeof description === 'string'
|
||||
? description
|
||||
: richTextToString(description),
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
pool,
|
||||
|
@ -124,6 +138,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
lastUpdatedTime,
|
||||
min,
|
||||
max,
|
||||
isLogScale,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
useAcceptedChallenges,
|
||||
useUserChallenges,
|
||||
} from 'web/lib/firebase/challenges'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
|
@ -29,6 +29,7 @@ import { copyToClipboard } from 'web/lib/util/copy'
|
|||
import toast from 'react-hot-toast'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { QRCode } from 'web/components/qr-code'
|
||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
|
||||
|
@ -37,6 +38,7 @@ const amountClass = columnClass + ' max-w-[75px] font-bold'
|
|||
export default function ChallengesListPage() {
|
||||
const user = useUser()
|
||||
const challenges = useAcceptedChallenges()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const userChallenges = useUserChallenges(user?.id)
|
||||
.concat(
|
||||
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
|
||||
|
@ -70,8 +72,25 @@ export default function ChallengesListPage() {
|
|||
<Col className="w-full px-8">
|
||||
<Row className="items-center justify-between">
|
||||
<Title text="Challenges" />
|
||||
{CHALLENGES_ENABLED && (
|
||||
<Button size="lg" color="gradient" onClick={() => setOpen(true)}>
|
||||
Create Challenge
|
||||
<CreateChallengeModal
|
||||
isOpen={open}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<p>Find or create a question to challenge someone to a bet.</p>
|
||||
<p>
|
||||
Want to create your own challenge?
|
||||
<SiteLink className={'mx-1 font-bold'} href={'/home'}>
|
||||
Find
|
||||
</SiteLink>
|
||||
a market you and a friend disagree on and hit the challenge button, or
|
||||
tap the button above to create a new market & challenge in one.
|
||||
</p>
|
||||
|
||||
<Tabs tabs={[...userTab, ...publicTab]} />
|
||||
</Col>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { Contract } from 'common/contract'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { useState } from 'react'
|
||||
import { AnswersGraph } from 'web/components/answers/answers-graph'
|
||||
import BetRow from 'web/components/bet-row'
|
||||
import { BetInline } from 'web/components/bet-inline'
|
||||
import { Button } from 'web/components/button'
|
||||
import {
|
||||
BinaryResolutionOrChance,
|
||||
FreeResponseResolutionOrChance,
|
||||
|
@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link'
|
|||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { listAllBets } from 'web/lib/firebase/bets'
|
||||
import {
|
||||
contractPath,
|
||||
|
@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
const href = `https://${DOMAIN}${contractPath(contract)}`
|
||||
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
const { setElem, height: topSectionHeight } = useMeasureSize()
|
||||
const paddingBottom = 8
|
||||
const { setElem, height: graphHeight } = useMeasureSize()
|
||||
|
||||
const graphHeight =
|
||||
windowHeight && topSectionHeight
|
||||
? windowHeight - topSectionHeight - paddingBottom
|
||||
: 0
|
||||
const [betPanelOpen, setBetPanelOpen] = useState(false)
|
||||
|
||||
const [probAfter, setProbAfter] = useState<number>()
|
||||
|
||||
return (
|
||||
<Col className="w-full flex-1 bg-white">
|
||||
<div className="relative flex flex-col pt-2" ref={setElem}>
|
||||
<Col className="h-[100vh] w-full bg-white">
|
||||
<div className="relative flex flex-col pt-2">
|
||||
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
||||
<SiteLink href={href}>{question}</SiteLink>
|
||||
</div>
|
||||
|
@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
disabled
|
||||
/>
|
||||
|
||||
{(isBinary || isPseudoNumeric) &&
|
||||
tradingAllowed(contract) &&
|
||||
!betPanelOpen && (
|
||||
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
||||
Bet
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isBinary && (
|
||||
<Row className="items-center gap-4">
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract as CPMMBinaryContract}
|
||||
betPanelClassName="scale-75"
|
||||
/>
|
||||
)}
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
</Row>
|
||||
<BinaryResolutionOrChance
|
||||
contract={contract}
|
||||
probAfter={probAfter}
|
||||
className="items-center"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<Row className="items-center gap-4">
|
||||
{tradingAllowed(contract) && (
|
||||
<BetRow contract={contract} betPanelClassName="scale-75" />
|
||||
)}
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
</Row>
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
|
@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
<Spacer h={2} />
|
||||
</div>
|
||||
|
||||
<div className="mx-1" style={{ paddingBottom }}>
|
||||
{(isBinary || isPseudoNumeric) && betPanelOpen && (
|
||||
<BetInline
|
||||
contract={contract as any}
|
||||
setProbAfter={setProbAfter}
|
||||
onClose={() => setBetPanelOpen(false)}
|
||||
className="self-center"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
|
||||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph
|
||||
contract={contract}
|
||||
|
|
|
@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
|||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { Button } from 'web/components/button'
|
||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||
import { Comment } from 'common/comment'
|
||||
import { GroupComment } from 'common/comment'
|
||||
import { GroupChat } from 'web/components/groups/group-chat'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
|
@ -123,7 +123,7 @@ export default function GroupPage(props: {
|
|||
topTraders: User[]
|
||||
creatorScores: { [userId: string]: number }
|
||||
topCreators: User[]
|
||||
messages: Comment[]
|
||||
messages: GroupComment[]
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
group: null,
|
||||
|
@ -607,7 +607,9 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
user={user}
|
||||
hideOrderSelector={true}
|
||||
onContractClick={addContractToCurrentGroup}
|
||||
gridClassName="gap-3 space-y-3"
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
additionalFilter={{ excludeContractIds: group.contractIds }}
|
||||
highlightOptions={{
|
||||
|
|
|
@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
|||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { GetServerSideProps } from 'next'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
|
||||
})
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
|
||||
return { props: { auth } }
|
||||
}
|
||||
|
||||
const Home = (props: { auth: { user: User } }) => {
|
||||
const { user } = props.auth
|
||||
const Home = (props: { auth: { user: User } | null }) => {
|
||||
const user = props.auth ? props.auth.user : null
|
||||
const [contract, setContract] = useContractPage()
|
||||
|
||||
const router = useRouter()
|
||||
|
@ -42,6 +45,7 @@ const Home = (props: { auth: { user: User } }) => {
|
|||
// Update the url without switching pages in Nextjs.
|
||||
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
|
||||
}}
|
||||
isWholePage
|
||||
/>
|
||||
</Col>
|
||||
<button
|
||||
|
|
|
@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) {
|
|||
<Col className="items-center">
|
||||
<Col className="max-w-3xl">
|
||||
<LandingPagePanel hotContracts={hotContracts ?? []} />
|
||||
{/* <p className="mt-6 text-gray-500">
|
||||
View{' '}
|
||||
<SiteLink href="/markets" className="font-bold text-gray-700">
|
||||
all markets
|
||||
</SiteLink>
|
||||
</p> */}
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ContractSearch } from '../components/contract-search'
|
||||
import { Page } from '../components/page'
|
||||
import { SEO } from '../components/SEO'
|
||||
|
||||
// TODO: Rename endpoint to "Explore"
|
||||
export default function Markets() {
|
||||
const user = useUser()
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Explore"
|
||||
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
|
||||
url="/markets"
|
||||
/>
|
||||
<ContractSearch user={user} />
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -31,7 +31,10 @@ import {
|
|||
import { TrendingUpIcon } from '@heroicons/react/outline'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
|
||||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
} from 'common/numeric-constants'
|
||||
import { groupBy, sum, uniq } from 'lodash'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Pagination } from 'web/components/pagination'
|
||||
|
@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: {
|
|||
(n) => n.sourceType
|
||||
)
|
||||
for (const sourceType in groupedNotificationsBySourceType) {
|
||||
// Source title splits by contracts and groups
|
||||
// Source title splits by contracts, groups, betting streak bonus
|
||||
const groupedNotificationsBySourceTitle = groupBy(
|
||||
groupedNotificationsBySourceType[sourceType],
|
||||
(notification) => {
|
||||
return notification.sourceTitle ?? notification.sourceContractTitle
|
||||
}
|
||||
)
|
||||
for (const contractId in groupedNotificationsBySourceTitle) {
|
||||
const notificationsForContractId =
|
||||
groupedNotificationsBySourceTitle[contractId]
|
||||
if (notificationsForContractId.length === 1) {
|
||||
newNotifications.push(notificationsForContractId[0])
|
||||
for (const sourceTitle in groupedNotificationsBySourceTitle) {
|
||||
const notificationsForSourceTitle =
|
||||
groupedNotificationsBySourceTitle[sourceTitle]
|
||||
if (notificationsForSourceTitle.length === 1) {
|
||||
newNotifications.push(notificationsForSourceTitle[0])
|
||||
continue
|
||||
}
|
||||
let sum = 0
|
||||
notificationsForContractId.forEach(
|
||||
notificationsForSourceTitle.forEach(
|
||||
(notification) =>
|
||||
notification.sourceText &&
|
||||
(sum = parseInt(notification.sourceText) + sum)
|
||||
)
|
||||
const uniqueUsers = uniq(
|
||||
notificationsForContractId.map((notification) => {
|
||||
notificationsForSourceTitle.map((notification) => {
|
||||
return notification.sourceUserUsername
|
||||
})
|
||||
)
|
||||
|
||||
const newNotification = {
|
||||
...notificationsForContractId[0],
|
||||
...notificationsForSourceTitle[0],
|
||||
sourceText: sum.toString(),
|
||||
sourceUserUsername:
|
||||
uniqueUsers.length > 1
|
||||
? MULTIPLE_USERS_KEY
|
||||
: notificationsForContractId[0].sourceType,
|
||||
: notificationsForSourceTitle[0].sourceType,
|
||||
}
|
||||
newNotifications.push(newNotification)
|
||||
}
|
||||
|
@ -362,7 +365,8 @@ function IncomeNotificationItem(props: {
|
|||
justSummary?: boolean
|
||||
}) {
|
||||
const { notification, justSummary } = props
|
||||
const { sourceType, sourceUserName, sourceUserUsername } = notification
|
||||
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
|
||||
notification
|
||||
const [highlighted] = useState(!notification.isSeen)
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width && width < 768) || false
|
||||
|
@ -370,19 +374,74 @@ function IncomeNotificationItem(props: {
|
|||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
||||
function getReasonForShowingIncomeNotification(simple: boolean) {
|
||||
function reasonAndLink(simple: boolean) {
|
||||
const { sourceText } = notification
|
||||
let reasonText = ''
|
||||
if (sourceType === 'bonus' && sourceText) {
|
||||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} unique traders`
|
||||
} unique traders on`
|
||||
: 'bonus on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you` : `in tips on`
|
||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
} else if (sourceType === 'betting_streak_bonus' && sourceText) {
|
||||
reasonText = `for your ${
|
||||
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
|
||||
}-day`
|
||||
}
|
||||
return reasonText
|
||||
return (
|
||||
<>
|
||||
{reasonText}
|
||||
{sourceType === 'betting_streak_bonus' ? (
|
||||
simple ? (
|
||||
<span className={'ml-1 font-bold'}>Betting Streak</span>
|
||||
) : (
|
||||
<SiteLink
|
||||
className={'ml-1 font-bold'}
|
||||
href={'/betting-streak-bonus'}
|
||||
>
|
||||
Betting Streak
|
||||
</SiteLink>
|
||||
)
|
||||
) : (
|
||||
<QuestionOrGroupLink
|
||||
notification={notification}
|
||||
ignoreClick={isMobile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const incomeNotificationLabel = () => {
|
||||
return sourceText ? (
|
||||
<span className="text-primary">
|
||||
{'+' + formatMoney(parseInt(sourceText))}
|
||||
</span>
|
||||
) : (
|
||||
<div />
|
||||
)
|
||||
}
|
||||
|
||||
const getIncomeSourceUrl = () => {
|
||||
const {
|
||||
sourceId,
|
||||
sourceContractCreatorUsername,
|
||||
sourceContractSlug,
|
||||
sourceSlug,
|
||||
} = notification
|
||||
if (sourceType === 'tip' && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||
if (sourceType === 'betting_streak_bonus')
|
||||
return `/${sourceUserUsername}/?show=betting-streak`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
sourceId ?? '',
|
||||
sourceType
|
||||
)}`
|
||||
}
|
||||
|
||||
if (justSummary) {
|
||||
|
@ -392,19 +451,9 @@ function IncomeNotificationItem(props: {
|
|||
<div className={'flex pl-1 sm:pl-0'}>
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
<div className={'mr-1 text-black'}>
|
||||
<NotificationTextLabel
|
||||
className={'line-clamp-1'}
|
||||
notification={notification}
|
||||
justSummary={true}
|
||||
/>
|
||||
{incomeNotificationLabel()}
|
||||
</div>
|
||||
<span className={'flex truncate'}>
|
||||
{getReasonForShowingIncomeNotification(true)}
|
||||
<QuestionOrGroupLink
|
||||
notification={notification}
|
||||
ignoreClick={isMobile}
|
||||
/>
|
||||
</span>
|
||||
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -421,18 +470,16 @@ function IncomeNotificationItem(props: {
|
|||
>
|
||||
<div className={'relative'}>
|
||||
<SiteLink
|
||||
href={getSourceUrl(notification) ?? ''}
|
||||
href={getIncomeSourceUrl() ?? ''}
|
||||
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
|
||||
/>
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<div className={'line-clamp-2 flex max-w-xl shrink '}>
|
||||
<div className={'inline'}>
|
||||
<span className={'mr-1'}>
|
||||
<NotificationTextLabel notification={notification} />
|
||||
</span>
|
||||
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
|
||||
</div>
|
||||
<span>
|
||||
{sourceType != 'bonus' &&
|
||||
{sourceType === 'tip' &&
|
||||
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
||||
<span className={'mr-1 truncate'}>Multiple users</span>
|
||||
) : (
|
||||
|
@ -443,8 +490,7 @@ function IncomeNotificationItem(props: {
|
|||
short={true}
|
||||
/>
|
||||
))}
|
||||
{getReasonForShowingIncomeNotification(false)} {' on'}
|
||||
<QuestionOrGroupLink notification={notification} />
|
||||
{reasonAndLink(false)}
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
|
@ -794,9 +840,6 @@ function getSourceUrl(notification: Notification) {
|
|||
// User referral:
|
||||
if (sourceType === 'user' && !sourceContractSlug)
|
||||
return `/${sourceUserUsername}`
|
||||
if (sourceType === 'tip' && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
|
@ -885,12 +928,6 @@ function NotificationTextLabel(props: {
|
|||
return (
|
||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||
)
|
||||
} else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
|
||||
return (
|
||||
<span className="text-primary">
|
||||
{'+' + formatMoney(parseInt(sourceText))}
|
||||
</span>
|
||||
)
|
||||
} else if (sourceType === 'bet' && sourceText) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ContractSearch } from '../../components/contract-search'
|
||||
import { Page } from '../../components/page'
|
||||
import { Title } from '../../components/title'
|
||||
|
||||
export default function TagPage() {
|
||||
const router = useRouter()
|
||||
const user = useUser()
|
||||
const { tag } = router.query as { tag: string }
|
||||
if (!router.isReady) return <div />
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Title text={`#${tag}`} />
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort="newest"
|
||||
defaultFilter="all"
|
||||
additionalFilter={{ tag }}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -18,7 +18,9 @@ online = false
|
|||
firstPrint = false
|
||||
flag = true
|
||||
page = 1
|
||||
sets = {}
|
||||
|
||||
window.console.log(sets)
|
||||
document.location.search.split('&').forEach((pair) => {
|
||||
let v = pair.split('=')
|
||||
if (v[0] === '?whichguesser') {
|
||||
|
@ -32,39 +34,38 @@ document.location.search.split('&').forEach((pair) => {
|
|||
}
|
||||
})
|
||||
|
||||
let firstFetch = fetch('jsons/' + whichGuesser + page + '.json')
|
||||
if (whichGuesser === 'basic') {
|
||||
fetch('jsons/set.json')
|
||||
.then((response) => response.json())
|
||||
.then((data) => (sets = data))
|
||||
}
|
||||
|
||||
let firstFetch = fetch('jsons/' + whichGuesser + '.json')
|
||||
fetchToResponse(firstFetch)
|
||||
|
||||
function putIntoMapAndFetch(data) {
|
||||
putIntoMap(data.data)
|
||||
if (data.has_more) {
|
||||
page += 1
|
||||
window.setTimeout(() =>
|
||||
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json'))
|
||||
for (const [key, value] of Object.entries(allData)) {
|
||||
nameList.push(key)
|
||||
probList.push(
|
||||
value.length + (probList.length === 0 ? 0 : probList[probList.length - 1])
|
||||
)
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(allData)) {
|
||||
nameList.push(key)
|
||||
probList.push(
|
||||
value.length +
|
||||
(probList.length === 0 ? 0 : probList[probList.length - 1])
|
||||
)
|
||||
unseenTotal = total
|
||||
}
|
||||
window.console.log(allData)
|
||||
window.console.log(total)
|
||||
window.console.log(probList)
|
||||
window.console.log(nameList)
|
||||
if (whichGuesser === 'counterspell') {
|
||||
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
|
||||
} else if (whichGuesser === 'burn') {
|
||||
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
|
||||
} else if (whichGuesser === 'beast') {
|
||||
document.getElementById('guess-type').innerText =
|
||||
'Finding Fantastic Beasts'
|
||||
}
|
||||
setUpNewGame()
|
||||
unseenTotal = total
|
||||
}
|
||||
window.console.log(allData)
|
||||
window.console.log(total)
|
||||
window.console.log(probList)
|
||||
window.console.log(nameList)
|
||||
if (whichGuesser === 'counterspell') {
|
||||
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
|
||||
} else if (whichGuesser === 'burn') {
|
||||
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
|
||||
} else if (whichGuesser === 'beast') {
|
||||
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
|
||||
} else if (whichGuesser === 'basic') {
|
||||
document.getElementById('guess-type').innerText = 'How Basic'
|
||||
}
|
||||
setUpNewGame()
|
||||
}
|
||||
|
||||
function getKSamples() {
|
||||
|
@ -134,11 +135,21 @@ function determineIfSkip(card) {
|
|||
}
|
||||
}
|
||||
if (firstPrint) {
|
||||
if (
|
||||
card.reprint === true ||
|
||||
(card.frame_effects && card.frame_effects.includes('showcase'))
|
||||
) {
|
||||
return true
|
||||
if (whichGuesser == 'basic') {
|
||||
if (
|
||||
card.set_type !== 'expansion' &&
|
||||
card.set_type !== 'funny' &&
|
||||
card.set_type !== 'draft_innovation'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
card.reprint === true ||
|
||||
(card.frame_effects && card.frame_effects.includes('showcase'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// reskinned card names show in art crop
|
||||
|
@ -160,13 +171,16 @@ function putIntoMap(data) {
|
|||
if (card.card_faces) {
|
||||
name = card.card_faces[0].name
|
||||
}
|
||||
if (whichGuesser === 'basic') {
|
||||
name =
|
||||
'<img class="symbol" style="width: 17px; height: 17px" src="' +
|
||||
sets[name][1] +
|
||||
'" /> ' +
|
||||
sets[name][0]
|
||||
}
|
||||
let normalImg = ''
|
||||
if (card.image_uris.normal) {
|
||||
normalImg = card.image_uris.normal
|
||||
} else if (card.image_uris.large) {
|
||||
normalImg = card.image_uris.large
|
||||
} else if (card.image_uris.small) {
|
||||
normalImg = card.image_uris.small
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
@ -211,7 +225,9 @@ function setUpNewGame() {
|
|||
artDict = sampledData[0]
|
||||
let randomImages = Object.keys(artDict)
|
||||
shuffleArray(randomImages)
|
||||
let namesList = Array.from(sampledData[1]).sort()
|
||||
let namesList = Array.from(sampledData[1]).sort((a, b) =>
|
||||
removeSymbol(a).localeCompare(removeSymbol(b))
|
||||
)
|
||||
// fill in the new cards and names
|
||||
for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
|
||||
let currCard = document.getElementById('card-' + cardIndex)
|
||||
|
@ -224,11 +240,16 @@ function setUpNewGame() {
|
|||
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
|
||||
currName = document.getElementById('name-' + nameIndex)
|
||||
// window.console.log(currName)
|
||||
currName.innerText = namesList[nameIndex - 1]
|
||||
currName.innerHTML = namesList[nameIndex - 1]
|
||||
nameBank.appendChild(currName)
|
||||
}
|
||||
}
|
||||
|
||||
function removeSymbol(name) {
|
||||
let arr = name.split('>')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
function checkAnswers() {
|
||||
let score = k
|
||||
// show the correct full cards
|
||||
|
@ -236,9 +257,13 @@ function checkAnswers() {
|
|||
currCard = document.getElementById('card-' + cardIndex)
|
||||
let incorrect = true
|
||||
if (currCard.dataset.name) {
|
||||
let guess = document.getElementById(currCard.dataset.name).innerText
|
||||
// window.console.log(artDict[currCard.dataset.url][0], guess);
|
||||
incorrect = artDict[currCard.dataset.url][0] !== guess
|
||||
// remove image text
|
||||
let guess = removeSymbol(
|
||||
document.getElementById(currCard.dataset.name).innerText
|
||||
)
|
||||
let ans = removeSymbol(artDict[currCard.dataset.url][0])
|
||||
window.console.log(ans, guess)
|
||||
incorrect = ans !== guess
|
||||
// decide if their guess was correct
|
||||
}
|
||||
if (incorrect) currCard.classList.add('incorrect')
|
||||
|
@ -352,6 +377,10 @@ function dropOnCard(id, data) {
|
|||
}
|
||||
|
||||
function setWordsLeft() {
|
||||
cardName = 'Unused Card Names: '
|
||||
if (whichGuesser === 'basic') {
|
||||
cardName = 'Unused Set Names: '
|
||||
}
|
||||
document.getElementById('words-left').innerText =
|
||||
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft
|
||||
cardName + wordsLeft + '/Images: ' + imagesLeft
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
}
|
||||
|
||||
.answer-page .card {
|
||||
height: 350px;
|
||||
height: 353px;
|
||||
/*padding-top: 310px;*/
|
||||
/*background-size: cover;*/
|
||||
overflow: hidden;
|
||||
|
@ -253,16 +253,6 @@
|
|||
.name {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 300px;
|
||||
background-size: 300px;
|
||||
height: 266px;
|
||||
}
|
||||
|
||||
.answer-page .card {
|
||||
height: 454px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
@ -3,7 +3,8 @@ import requests
|
|||
import json
|
||||
|
||||
# add category name here
|
||||
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn']
|
||||
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
|
||||
specialCategories = ['set', 'basic']
|
||||
|
||||
|
||||
def generate_initial_query(category):
|
||||
|
@ -12,11 +13,11 @@ def generate_initial_query(category):
|
|||
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
|
||||
elif category == 'beast':
|
||||
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
|
||||
elif category == 'terror':
|
||||
string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||
'%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||
elif category == 'wrath':
|
||||
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
|
||||
# elif category == 'terror':
|
||||
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||
# elif category == 'wrath':
|
||||
# string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
|
||||
elif category == 'burn':
|
||||
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
|
||||
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
|
||||
|
@ -24,9 +25,19 @@ def generate_initial_query(category):
|
|||
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
|
||||
# add category string query here
|
||||
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
|
||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
||||
'+-frame%3Aextendedart+language%3Aenglish&unique=art&page='
|
||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
||||
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
|
||||
print(string_query)
|
||||
return string_query
|
||||
|
||||
def generate_initial_special_query(category):
|
||||
string_query = 'https://api.scryfall.com/cards/search?q='
|
||||
if category == 'set':
|
||||
return 'https://api.scryfall.com/sets'
|
||||
elif category == 'basic':
|
||||
string_query += 't%3Abasic&order=released&dir=asc&unique=prints&page='
|
||||
# add category string query here
|
||||
print(string_query)
|
||||
return string_query
|
||||
|
||||
|
@ -34,31 +45,60 @@ def generate_initial_query(category):
|
|||
def fetch_and_write_all(category, query):
|
||||
count = 1
|
||||
will_repeat = True
|
||||
all_cards = {'data' : []}
|
||||
art_names = set()
|
||||
while will_repeat:
|
||||
will_repeat = fetch_and_write(category, query, count)
|
||||
count += 1
|
||||
response = fetch(query, count)
|
||||
will_repeat = response['has_more']
|
||||
count+=1
|
||||
to_compact_write_form(all_cards, art_names, response, category)
|
||||
|
||||
with open('jsons/' + category + '.json', 'w') as f:
|
||||
json.dump(all_cards, f)
|
||||
|
||||
|
||||
def fetch_and_write(category, query, count):
|
||||
def fetch_and_write_all_special(category, query):
|
||||
count = 1
|
||||
will_repeat = True
|
||||
all_cards = {'data' : []}
|
||||
art_names = set()
|
||||
while will_repeat:
|
||||
if category == 'set':
|
||||
response = fetch_special(query)
|
||||
else:
|
||||
response = fetch(query, count)
|
||||
will_repeat = response['has_more']
|
||||
count+=1
|
||||
to_compact_write_form_special(all_cards, art_names, response, category)
|
||||
|
||||
with open('jsons/' + category + '.json', 'w') as f:
|
||||
json.dump(all_cards, f)
|
||||
|
||||
|
||||
|
||||
def fetch(query, count):
|
||||
query += str(count)
|
||||
response = requests.get(f"{query}").json()
|
||||
time.sleep(0.1)
|
||||
with open('jsons/' + category + str(count) + '.json', 'w') as f:
|
||||
json.dump(to_compact_write_form(response), f)
|
||||
return response['has_more']
|
||||
return response
|
||||
|
||||
def fetch_special(query):
|
||||
response = requests.get(f"{query}").json()
|
||||
time.sleep(0.1)
|
||||
return response
|
||||
|
||||
|
||||
def to_compact_write_form(response):
|
||||
fieldsToUse = ['has_more']
|
||||
def to_compact_write_form(smallJson, art_names, response, category):
|
||||
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
|
||||
'set_type']
|
||||
smallJson = dict()
|
||||
data = []
|
||||
# write all fields needed in response
|
||||
for field in fieldsToUse:
|
||||
smallJson[field] = response[field]
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
# do not repeat art
|
||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card['illustration_id'])
|
||||
write_card = dict()
|
||||
for field in fieldsInCard:
|
||||
if field == 'name' and 'card_faces' in card:
|
||||
|
@ -68,8 +108,33 @@ def to_compact_write_form(response):
|
|||
elif field in card:
|
||||
write_card[field] = card[field]
|
||||
data.append(write_card)
|
||||
smallJson['data'] = data
|
||||
return smallJson
|
||||
smallJson['data'] += data
|
||||
|
||||
def to_compact_write_form_special(smallJson, art_names, response, category):
|
||||
fieldsInBasic = ['image_uris', 'set', 'set_type', 'digital']
|
||||
data = []
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
if category == 'basic':
|
||||
write_card = dict()
|
||||
# do not repeat art
|
||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card['illustration_id'])
|
||||
for field in fieldsInBasic:
|
||||
if field == 'image_uris':
|
||||
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
||||
elif field == 'set':
|
||||
write_card['name'] = card['set']
|
||||
elif field in card:
|
||||
write_card[field] = card[field]
|
||||
data.append(write_card)
|
||||
else:
|
||||
if card['set_type'] != 'token':
|
||||
smallJson[card['code']] = [card['name'],card['icon_svg_uri']]
|
||||
|
||||
smallJson['data'] += data
|
||||
|
||||
|
||||
# only write images needed
|
||||
|
@ -87,6 +152,9 @@ def write_image_uris(card_image_uris):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for category in allCategories:
|
||||
# for category in allCategories:
|
||||
# print(category)
|
||||
# fetch_and_write_all(category, generate_initial_query(category))
|
||||
for category in specialCategories:
|
||||
print(category)
|
||||
fetch_and_write_all(category, generate_initial_query(category))
|
||||
fetch_and_write_all_special(category, generate_initial_special_query(category))
|
||||
|
|
|
@ -159,6 +159,16 @@
|
|||
>
|
||||
<br />
|
||||
|
||||
<input type="radio" id="basic" name="whichguesser" value="basic" />
|
||||
<label class="radio-label" for="basic">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
|
||||
/>
|
||||
<h3>How Basic</h3></label
|
||||
>
|
||||
<br />
|
||||
|
||||
<details id="addl-options">
|
||||
<summary>
|
||||
<img
|
||||
|
@ -174,7 +184,7 @@
|
|||
<label for="un">include un-cards</label>
|
||||
<br />
|
||||
<input type="checkbox" name="original" id="original" />
|
||||
<label for="original">restrict to only original printing</label>
|
||||
<label for="original">only original set printing</label>
|
||||
</details>
|
||||
<input type="submit" id="submit" value="Play" />
|
||||
</form>
|
||||
|
|
1
web/public/mtg/jsons/basic.json
Normal file
1
web/public/mtg/jsons/basic.json
Normal file
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/beast.json
Normal file
1
web/public/mtg/jsons/beast.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/burn.json
Normal file
1
web/public/mtg/jsons/burn.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/counterspell.json
Normal file
1
web/public/mtg/jsons/counterspell.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user