Merge branch 'main' into markets-emails

This commit is contained in:
Ian Philips 2022-08-19 11:36:22 -06:00 committed by GitHub
commit a536d38376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 1767 additions and 1678 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
- [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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr>
<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

View File

@ -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;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr>
<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;">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,127 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { APIError } from 'web/lib/firebase/api'
import { useEffect, useState } from 'react'
import { useMutation } from 'react-query'
import { placeBet } from 'web/lib/firebase/api'
import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
// adapted from bet-panel.ts
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
setProbAfter: (probAfter: number) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
const user = useUser()
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
const [amount, setAmount] = useState<number>()
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
const submitBet = useMutation(
() => placeBet({ outcome, amount, contractId: contract.id }),
{
onError: (e) =>
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
onSuccess: () => {
track('bet', {
location: 'embed',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount,
outcome,
isLimitOrder: false,
})
setAmount(undefined)
},
}
)
// reset error / success state on user change
useEffect(() => {
amount && submitBet.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [outcome, amount])
const tooFewFunds = error === 'Insufficient balance'
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
return (
<Col className={clsx('items-center', className)}>
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
<div className="text-xl">Bet</div>
<YesNoSelector
className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome}
onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric}
/>
<BuyAmountInput
className="-mb-4"
inputClassName={clsx(
'input-sm w-20 !text-base',
error && 'input-error'
)}
amount={amount}
onChange={setAmount}
error="" // handle error ourselves
setError={setError}
/>
{user && (
<Button
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
size="xs"
disabled={betDisabled}
onClick={() => submitBet.mutate()}
>
{submitBet.isLoading
? 'Submitting'
: submitBet.isSuccess
? 'Success!'
: 'Submit'}
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<XIcon className="ml-1 h-6 w-6" />
</button>
</Row>
{error && (
<div className="text-error my-1 text-sm">
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
</div>
)}
</Col>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Contract } from 'web/lib/firebase/contracts'
import { 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
return
}
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,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'
import { 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,10 @@ import {
import { TrendingUpIcon } from '@heroicons/react/outline'
import { 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 (
<>

View File

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

View File

@ -18,7 +18,9 @@ online = false
firstPrint = false
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
}

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More