Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-09-08 14:57:08 -07:00 committed by GitHub
commit d2687d9b1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
246 changed files with 8729 additions and 7440 deletions

43
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Run linter (remove unused imports)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
# mqp - i generated a personal token to use for these writes -- it's unclear
# why, but the default token didn't work, even when i gave it max permissions
jobs:
lint:
name: Auto-lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.FORMATTER_ACCESS_TOKEN }}
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run lint script
run: yarn lint
- name: Commit any lint changes
if: always()
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Auto-remove unused imports
branch: ${{ github.head_ref }}

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
plugins: ['lodash'], plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
ignorePatterns: ['lib'], ignorePatterns: ['lib'],
env: { env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error',
}, },
}, },
], ],

158
common/calculate-metrics.ts Normal file
View File

@ -0,0 +1,158 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProbChangeSince = (descendingBets: Bet[], since: number) => {
const newestBet = descendingBets[0]
if (!newestBet) return 0
const betBeforeSince = descendingBets.find((b) => b.createdTime < since)
if (!betBeforeSince) {
const oldestBet = last(descendingBets) ?? newestBet
return newestBet.probAfter - oldestBet.probBefore
}
return newestBet.probAfter - betBeforeSince.probAfter
}
export const calculateProbChanges = (descendingBets: Bet[]) => {
const now = Date.now()
const yesterday = now - DAY_MS
const weekAgo = now - 7 * DAY_MS
const monthAgo = now - 30 * DAY_MS
return {
day: calculateProbChangeSince(descendingBets, yesterday),
week: calculateProbChangeSince(descendingBets, weekAgo),
month: calculateProbChangeSince(descendingBets, monthAgo),
}
}
export const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
export const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
const startingProfit = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit
}
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}

View File

@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) {
const sortedBets = sortBy(yourBets, 'createdTime') const sortedBets = sortBy(yourBets, 'createdTime')
for (const bet of sortedBets) { for (const bet of sortedBets) {
const { outcome, shares, amount } = bet const { outcome, shares, amount } = bet
if (floatingEqual(shares, 0)) continue
if (amount > 0) { if (amount > 0) {
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount

View File

@ -1,6 +1,6 @@
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
@ -20,19 +20,31 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string userAvatarUrl?: string
} & T } & T
type OnContract = { export type OnContract = {
commentType: 'contract' commentType: 'contract'
contractId: string contractId: string
contractSlug: string
contractQuestion: string
answerOutcome?: string answerOutcome?: string
betId?: string betId?: string
// denormalized from contract
contractSlug: string
contractQuestion: string
// denormalized from bet
betAmount?: number
betOutcome?: string
} }
type OnGroup = { export type OnGroup = {
commentType: 'group' commentType: 'group'
groupId: string groupId: string
} }
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract> export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup> export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) {
export function contractTextDetails(contract: Contract) { export function contractTextDetails(contract: Contract) {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const dayjs = require('dayjs') const dayjs = require('dayjs')
const { closeTime, tags } = contract const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`) const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
return ( return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) {
).format('MMM D, h:mma')}` ).format('MMM D, h:mma')}`
: '') + : '') +
`${volumeLabel}` + `${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '') (groupHashtags ? `${groupHashtags.join(' ')}` : '')
) )
} }
@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => {
creatorAvatarUrl, creatorAvatarUrl,
description, description,
numericValue, numericValue,
resolution,
} }
} }
@ -103,6 +104,7 @@ export type OgCardProps = {
creatorUsername: string creatorUsername: string
creatorAvatarUrl?: string creatorAvatarUrl?: string
numericValue?: string numericValue?: string
resolution?: string
} }
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
creatorOutcome, creatorOutcome,
acceptorOutcome, acceptorOutcome,
} = challenge || {} } = challenge || {}
const {
probability,
numericValue,
resolution,
creatorAvatarUrl,
question,
metadata,
creatorUsername,
creatorName,
} = props
const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
const probabilityParam = const probabilityParam =
props.probability === undefined probability === undefined
? '' ? ''
: `&probability=${encodeURIComponent(props.probability ?? '')}` : `&probability=${encodeURIComponent(probability ?? '')}`
const numericValueParam = const numericValueParam =
props.numericValue === undefined numericValue === undefined
? '' ? ''
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` : `&numericValue=${encodeURIComponent(numericValue ?? '')}`
const creatorAvatarUrlParam = const creatorAvatarUrlParam =
props.creatorAvatarUrl === undefined creatorAvatarUrl === undefined
? '' ? ''
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}`
const challengeUrlParams = challenge const challengeUrlParams = challenge
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
: '' : ''
const resolutionUrlParam = resolution
? `&resolution=${encodeURIComponent(resolution)}`
: ''
// URL encode each of the props, then add them as query params // URL encode each of the props, then add them as query params
return ( return (
`https://manifold-og-image.vercel.app/m.png` + `https://manifold-og-image.vercel.app/m.png` +
`?question=${encodeURIComponent(props.question)}` + `?question=${encodeURIComponent(question)}` +
probabilityParam + probabilityParam +
numericValueParam + numericValueParam +
`&metadata=${encodeURIComponent(props.metadata)}` + `&metadata=${encodeURIComponent(metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` + `&creatorName=${encodeURIComponent(creatorName)}` +
creatorAvatarUrlParam + creatorAvatarUrlParam +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + `&creatorUsername=${encodeURIComponent(creatorUsername)}` +
challengeUrlParams challengeUrlParams +
resolutionUrlParam
) )
} }

View File

@ -59,6 +59,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
popularityScore?: number popularityScore?: number
followerCount?: number followerCount?: number
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
@ -85,6 +87,12 @@ export type CPMM = {
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$ totalLiquidity: number // in M$
prob: number
probChanges: {
day: number
week: number
month: number
}
} }
export type Binary = { export type Binary = {

View File

@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
/-/g,
'_'
)}`
// Manifold's domain or any subdomains thereof // Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp( export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'

View File

@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian 'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid 'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

@ -6,12 +6,11 @@ export type Group = {
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
mostRecentActivityTime: number mostRecentActivityTime: number
memberIds: string[] // User ids
anyoneCanJoin: boolean anyoneCanJoin: boolean
contractIds: string[] totalContracts: number
totalMembers: number
aboutPostId?: string
chatDisabled?: boolean chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number mostRecentContractAddedTime?: number
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75

8
common/like.ts Normal file
View File

@ -0,0 +1,8 @@
export type Like = {
id: string // will be id of the object liked, i.e. contract.id
userId: string
type: 'contract'
createdTime: number
tipTxnId?: string // only holds most recent tip txn id
}
export const LIKE_TIP_AMOUNT = 5

View File

@ -10,7 +10,7 @@ import {
import { PortfolioMetrics, User } from './user' import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array' import { filterDefined } from './util/array'
const LOAN_DAILY_RATE = 0.01 const LOAN_DAILY_RATE = 0.02
const calculateNewLoan = (investedValue: number, loanTotal: number) => { const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal const netValue = investedValue - loanTotal
@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale) const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
return openBets.map((bet) => { return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0 const loanAmount = bet.loanAmount ?? 0

View File

@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
initialProbability: p, initialProbability: p,
p, p,
pool: pool, pool: pool,
prob: initialProb,
probChanges: { day: 0, week: 0, month: 0 },
} }
return system return system

View File

@ -15,6 +15,7 @@ export type Notification = {
sourceUserUsername?: string sourceUserUsername?: string
sourceUserAvatarUrl?: string sourceUserAvatarUrl?: string
sourceText?: string sourceText?: string
data?: string
sourceContractTitle?: string sourceContractTitle?: string
sourceContractCreatorUsername?: string sourceContractCreatorUsername?: string
@ -40,6 +41,8 @@ export type notification_source_types =
| 'challenge' | 'challenge'
| 'betting_streak_bonus' | 'betting_streak_bonus'
| 'loan' | 'loan'
| 'like'
| 'tip_and_like'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -71,3 +74,5 @@ export type notification_reason_types =
| 'betting_streak_incremented' | 'betting_streak_incremented'
| 'loan_income' | 'loan_income'
| 'you_follow_contract' | 'you_follow_contract'
| 'liked_your_contract'
| 'liked_and_tipped_your_contract'

View File

@ -8,11 +8,11 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tiptap/core": "2.0.0-beta.181", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.191",
"lodash": "4.17.21" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {

12
common/post.ts Normal file
View File

@ -0,0 +1,12 @@
import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
content: JSONContent
creatorId: string // User id
createdTime: number
slug: string
}
export const MAX_POST_TITLE_LENGTH = 480

View File

@ -34,6 +34,7 @@ export type User = {
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
homeSections?: { visible: string[]; hidden: string[] }
referredByUserId?: string referredByUserId?: string
referredByContractId?: string referredByContractId?: string
@ -44,6 +45,7 @@ export type User = {
currentBettingStreak?: number currentBettingStreak?: number
hasSeenContractFollowModal?: boolean hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number freeMarketsCreated?: number
isBannedFromPosting?: boolean
} }
export type PrivateUser = { export type PrivateUser = {

View File

@ -1,6 +1,6 @@
import { union } from 'lodash' import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => { export const removeUndefinedProps = <T extends object>(obj: T): T => {
const newObj: any = {} const newObj: any = {}
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
@ -37,4 +37,3 @@ export const subtractObjects = <T extends { [key: string]: number }>(
return newObj as T return newObj as T
} }

View File

@ -1,2 +1,3 @@
export const HOUR_MS = 60 * 60 * 1000 export const MINUTE_MS = 60 * 1000
export const HOUR_MS = 60 * MINUTE_MS
export const DAY_MS = 24 * HOUR_MS export const DAY_MS = 24 * HOUR_MS

View File

@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({
HTMLAttributes: { HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses, class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;', style: 'padding-bottom: 20rem; ',
}, },
} }
}, },
@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({
frameborder: { frameborder: {
default: 0, default: 0,
}, },
height: {
default: 0,
},
allowfullscreen: { allowfullscreen: {
default: this.options.allowFullscreen, default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen,
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
this.options.HTMLAttributes.style =
this.options.HTMLAttributes.style +
' height: ' +
HTMLAttributes.height +
';'
return [ return [
'div', 'div',
this.options.HTMLAttributes, this.options.HTMLAttributes,

View File

@ -54,6 +54,10 @@ Returns the authenticated user.
Gets all groups, in no particular order. Gets all groups, in no particular order.
Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned.
Requires no authorization. Requires no authorization.
### `GET /v0/groups/[slug]` ### `GET /v0/groups/[slug]`
@ -62,12 +66,18 @@ Gets a group by its slug.
Requires no authorization. Requires no authorization.
### `GET /v0/groups/by-id/[id]` ### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID. Gets a group by its unique ID.
Requires no authorization. Requires no authorization.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
### `GET /v0/markets` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.

View File

@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
## API / Dev ## API / Dev
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
- [PyManifold fork](https://github.com/gappleto97/PyManifold/) - Fork maintained by [@LivInTheLookingGlass](https://manifold.markets/LivInTheLookingGlass)
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
@ -24,3 +25,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets - [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
## Writeups
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
- [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown
- [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton
## Art
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)

View File

@ -22,6 +22,20 @@
} }
] ]
}, },
{
"collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "isFilled",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"
}
]
},
{ {
"collectionGroup": "bets", "collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",
@ -36,6 +50,70 @@
} }
] ]
}, },
{
"collectionGroup": "bets",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isCancelled",
"order": "ASCENDING"
},
{
"fieldPath": "isFilled",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "challenges",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "commentType",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{ {
"collectionGroup": "comments", "collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",
@ -78,6 +156,42 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "groupSlugs",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -124,6 +238,46 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",

View File

@ -12,7 +12,9 @@ service cloud.firestore {
'taowell@gmail.com', 'taowell@gmail.com',
'abc.sinclair@gmail.com', 'abc.sinclair@gmail.com',
'manticmarkets@gmail.com', 'manticmarkets@gmail.com',
'iansphilips@gmail.com' 'iansphilips@gmail.com',
'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com'
] ]
} }
@ -62,6 +64,11 @@ service cloud.firestore {
allow write: if request.auth.uid == userId; allow write: if request.auth.uid == userId;
} }
match /users/{userId}/likes/{likeId} {
allow read;
allow write: if request.auth.uid == userId;
}
match /{somePath=**}/follows/{followUserId} { match /{somePath=**}/follows/{followUserId} {
allow read; allow read;
} }
@ -155,24 +162,52 @@ service cloud.firestore {
.hasOnly(['isSeen', 'viewTime']); .hasOnly(['isSeen', 'viewTime']);
} }
match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
match /{somePath=**}/groupContracts/{contractId} {
allow read;
}
match /groups/{groupId} { match /groups/{groupId} {
allow read; allow read;
allow update: if request.auth.uid == resource.data.creatorId allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly([ 'contractIds', 'memberIds' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
function isMember() { match /groupContracts/{contractId} {
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
} }
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
match /comments/{commentId} { match /comments/{commentId} {
allow read; allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /posts/{postId} {
allow read;
allow update: if isAdmin() || request.auth.uid == resource.data.creatorId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
} }
} }
} }

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
plugins: ['lodash'], plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
ignorePatterns: ['dist', 'lib'], ignorePatterns: ['dist', 'lib'],
env: { env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error',
}, },
}, },
], ],

View File

@ -26,11 +26,11 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.181", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.191",
"cors": "2.8.5", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "express": "4.18.1",

View File

@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
mostRecentActivityTime: Date.now(), mostRecentActivityTime: Date.now(),
// TODO: allow users to add contract ids on group creation // TODO: allow users to add contract ids on group creation
contractIds: [],
anyoneCanJoin, anyoneCanJoin,
memberIds, totalContracts: 0,
totalMembers: memberIds.length,
} }
await groupRef.create(group) await groupRef.create(group)
// create a GroupMemberDoc for each member
await Promise.all(
memberIds.map((memberId) =>
groupRef.collection('groupMembers').doc(memberId).create({
userId: memberId,
createdTime: Date.now(),
})
)
)
return { status: 'success', group: group } return { status: 'success', group: group }
}) })

View File

@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
group = groupDoc.data() as Group group = groupDoc.data() as Group
const groupMembersSnap = await firestore
.collection(`groups/${groupId}/groupMembers`)
.get()
const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number }
)
if ( if (
!group.memberIds.includes(user.id) && !groupMemberDocs.map((m) => m.userId).includes(user.id) &&
!group.anyoneCanJoin && !group.anyoneCanJoin &&
group.creatorId !== user.id group.creatorId !== user.id
) { ) {
@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
await contractRef.create(contract) await contractRef.create(contract)
if (group != null) { if (group != null) {
if (!group.contractIds.includes(contractRef.id)) { const groupContractsSnap = await firestore
.collection(`groups/${groupId}/groupContracts`)
.get()
const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number }
)
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid) await createGroupLinks(group, [contractRef.id], auth.uid)
const groupDocRef = firestore.collection('groups').doc(group.id) const groupContractRef = firestore
groupDocRef.update({ .collection(`groups/${groupId}/groupContracts`)
contractIds: uniq([...group.contractIds, contractRef.id]), .doc(contract.id)
await groupContractRef.set({
contractId: contract.id,
createdTime: Date.now(),
}) })
} }
} }

View File

@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge' import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse' import { richTextToString } from '../../common/util/parse'
import { Like } from '../../common/like'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
@ -150,15 +151,6 @@ export const createNotification = async (
} }
} }
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
@ -191,16 +183,6 @@ export const createNotification = async (
sourceContract sourceContract
) { ) {
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
@ -689,3 +671,85 @@ export const createBettingStreakBonusNotification = async (
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
export const createLikeNotification = async (
fromUser: User,
toUser: User,
like: Like,
idempotencyKey: string,
contract: Contract,
tip?: TipTxn
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: like.id,
sourceType: tip ? 'tip_and_like' : 'like',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: tip?.amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export async function filterUserIdsForOnlyFollowerIds(
userIds: string[],
contractId: string
) {
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${contractId}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
return userIds.filter((id) => contractFollowersIds.includes(id))
}
export const createUniqueBettorBonusNotification = async (
contractCreatorId: string,
bettor: User,
txnId: string,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// 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

@ -0,0 +1,83 @@
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema,
})
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
throw new APIError(400, 'No user exists with the authenticated user ID.')
console.log('creating post owned by', creator.username, 'titled', title)
const slug = await getSlug(title)
const postRef = firestore.collection('posts').doc()
const post: Post = {
id: postRef.id,
creatorId: creator.id,
slug,
title,
createdTime: Date.now(),
content: content,
}
await postRef.create(post)
return { status: 'success', post }
})
export const getSlug = async (title: string) => {
const proposedSlug = slugify(title)
const preexistingPost = await getPostFromSlug(proposedSlug)
return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug
}
export async function getPostFromSlug(slug: string) {
const firestore = admin.firestore()
const snap = await firestore
.collection('posts')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Post)
}

View File

@ -1,6 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { uniq } from 'lodash'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils' import { getUser, getUserByUsername, getValues } from './utils'
@ -17,7 +16,7 @@ import {
import { track } from './analytics' import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' import { Group } from '../../common/group'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({ const bodySchema = z.object({
@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
firestore.collection('groups').where('slug', '==', slug) firestore.collection('groups').where('slug', '==', slug)
) )
await firestore await firestore
.collection('groups') .collection(`groups/${groups[0].id}/groupMembers`)
.doc(groups[0].id) .doc(user.id)
.update({ .set({ userId: user.id, createdTime: Date.now() })
memberIds: uniq(groups[0].memberIds.concat(user.id)),
})
}
for (const slug of NEW_USER_GROUP_SLUGS) {
const groups = await getValues<Group>(
firestore.collection('groups').where('slug', '==', slug)
)
const group = groups[0]
await firestore
.collection('groups')
.doc(group.id)
.update({
memberIds: uniq(group.memberIds.concat(user.id)),
})
} }
} }

View File

@ -1,13 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market answer</title> <title>Market answer</title>
@ -33,52 +32,60 @@
body { body {
padding: 0 !important; padding: 0 !important;
} }
h1 { h1 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h2 { h2 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h3 { h3 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h4 { h4 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h1 { h1 {
font-size: 22px !important; font-size: 22px !important;
} }
h2 { h2 {
font-size: 18px !important; font-size: 18px !important;
} }
h3 { h3 {
font-size: 16px !important; font-size: 16px !important;
} }
.container { .container {
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
.content { .content {
padding: 0 !important; padding: 0 !important;
} }
.content-wrap { .content-wrap {
padding: 10px !important; padding: 10px !important;
} }
.invoice { .invoice {
width: 100% !important; width: 100% !important;
} }
} }
</style> </style>
</head> </head>
<body <body itemscope itemtype="http://schema.org/EmailMessage" style="
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
" ">
> <table class="main" width="100%" cellpadding="0" cellspacing="0" style="
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0; margin: 0;
padding: 0 0 0px 0; padding: 0 0 0px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -266,19 +218,15 @@
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
margin-top: 10px; margin-top: 10px;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -286,37 +234,26 @@
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
font-weight: bold; font-weight: bold;
" " valign="top">
valign="top"
>
<div> <div>
<img <img src="{{avatarUrl}}" width="30" height="30" style="
src="{{avatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px; border-radius: 30px;
overflow: hidden; overflow: hidden;
vertical-align: middle; vertical-align: middle;
margin-right: 4px; margin-right: 4px;
" " alt="" />
alt=""
/>
{{name}} {{name}}
</div> </div>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -324,40 +261,29 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top" <div style="
>
<div
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <span style="white-space: pre-line">{{answer}}</span>
<span style="white-space: pre-line"
>{{answer}}</span
>
</div> </div>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
>
<td style="padding: 20px 0 0 0; margin: 0"> <td style="padding: 20px 0 0 0; margin: 0">
<div align="center"> <div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]--> <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a <a href="{{marketUrl}}" target="_blank" style="
href="{{marketUrl}}"
target="_blank"
style="
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -375,23 +301,16 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View answer</span></span>
>View answer</span
></span
>
</a> </a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div> </div>
@ -404,9 +323,7 @@
</td> </td>
</tr> </tr>
</table> </table>
<div <div class="footer" style="
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -415,28 +332,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -446,14 +355,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center"
valign="top"
>
Questions? Come ask in Questions? Come ask in
<a <a href="https://discord.gg/eHQBNBqXuh" style="
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -461,12 +365,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -474,26 +374,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a
>.
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</td> </td>
<td <td style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top"
></td>
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -1,13 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market closed</title> <title>Market closed</title>
@ -33,52 +32,60 @@
body { body {
padding: 0 !important; padding: 0 !important;
} }
h1 { h1 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h2 { h2 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h3 { h3 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h4 { h4 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h1 { h1 {
font-size: 22px !important; font-size: 22px !important;
} }
h2 { h2 {
font-size: 18px !important; font-size: 18px !important;
} }
h3 { h3 {
font-size: 16px !important; font-size: 16px !important;
} }
.container { .container {
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
.content { .content {
padding: 0 !important; padding: 0 !important;
} }
.content-wrap { .content-wrap {
padding: 10px !important; padding: 10px !important;
} }
.invoice { .invoice {
width: 100% !important; width: 100% !important;
} }
} }
</style> </style>
</head> </head>
<body <body itemscope itemtype="http://schema.org/EmailMessage" style="
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
" ">
> <table class="main" width="100%" cellpadding="0" cellspacing="0" style="
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0; margin: 0;
padding: 0 0 40px 0; padding: 0 0 40px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
padding: 0; padding: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0; margin: 0;
padding: 0 0 6px 0; padding: 0 0 6px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top"
>
You asked You asked
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " valign="top">
valign="top" <a href="{{url}}" style="
>
<a
href="{{url}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif; 'Lucida Grande', sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9; color: #4337c9;
display: block; display: block;
text-decoration: none; text-decoration: none;
" ">
> {{question}}</a>
{{question}}</a
>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 0 0 0px; padding: 0 0 0px;
" " valign="top">
valign="top" <h2 class="aligncenter" style="
>
<h2
class="aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif; 'Lucida Grande', sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
margin: 10px 0 0; margin: 10px 0 0;
" " align="center">
align="center"
>
Market closed Market closed
</h2> </h2>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left; text-align: left;
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -396,116 +318,90 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top"
>
Hi {{name}}, Hi {{name}},
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
A market you created has closed. It's attracted A market you created has closed. It's attracted
<span style="font-weight: bold">{{volume}}</span> <span style="font-weight: bold">{{volume}}</span>
in bets — congrats! in bets — congrats!
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> Please resolve your market.
Resolve your market to earn {{creatorFee}} as the <br style="
creator commission.
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Thanks, Thanks,
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Manifold Team Manifold Team
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
>
<td style="padding: 10px 0 0 0; margin: 0"> <td style="padding: 10px 0 0 0; margin: 0">
<div align="center"> <div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]--> <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a <a href="{{url}}" target="_blank" style="
href="{{url}}"
target="_blank"
style="
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -523,23 +419,16 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View market</span></span>
>View market</span
></span
>
</a> </a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div> </div>
@ -552,9 +441,7 @@
</td> </td>
</tr> </tr>
</table> </table>
<div <div class="footer" style="
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -563,28 +450,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -594,14 +473,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center"
valign="top"
>
Questions? Come ask in Questions? Come ask in
<a <a href="https://discord.gg/eHQBNBqXuh" style="
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -609,12 +483,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -622,26 +492,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a
>.
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</td> </td>
<td <td style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top"
></td>
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -1,13 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market comment</title> <title>Market comment</title>
@ -33,52 +32,60 @@
body { body {
padding: 0 !important; padding: 0 !important;
} }
h1 { h1 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h2 { h2 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h3 { h3 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h4 { h4 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h1 { h1 {
font-size: 22px !important; font-size: 22px !important;
} }
h2 { h2 {
font-size: 18px !important; font-size: 18px !important;
} }
h3 { h3 {
font-size: 16px !important; font-size: 16px !important;
} }
.container { .container {
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
.content { .content {
padding: 0 !important; padding: 0 !important;
} }
.content-wrap { .content-wrap {
padding: 10px !important; padding: 10px !important;
} }
.invoice { .invoice {
width: 100% !important; width: 100% !important;
} }
} }
</style> </style>
</head> </head>
<body <body itemscope itemtype="http://schema.org/EmailMessage" style="
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
" ">
> <table class="main" width="100%" cellpadding="0" cellspacing="0" style="
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0; margin: 0;
padding: 0 0 0px 0; padding: 0 0 0px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -266,59 +218,42 @@
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
margin-top: 10px; margin-top: 10px;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top"
>
<div> <div>
<img <img src="{{commentorAvatarUrl}}" width="30" height="30" style="
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px; border-radius: 30px;
overflow: hidden; overflow: hidden;
vertical-align: middle; vertical-align: middle;
margin-right: 4px; margin-right: 4px;
" " alt="" />
alt="" <span style="font-weight: bold">{{commentorName}}</span>
/>
<span style="font-weight: bold"
>{{commentorName}}</span
>
{{betDescription}} {{betDescription}}
</div> </div>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -326,40 +261,29 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top" <div style="
>
<div
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <span style="white-space: pre-line">{{comment}}</span>
<span style="white-space: pre-line"
>{{comment}}</span
>
</div> </div>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
>
<td style="padding: 20px 0 0 0; margin: 0"> <td style="padding: 20px 0 0 0; margin: 0">
<div align="center"> <div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]--> <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a <a href="{{marketUrl}}" target="_blank" style="
href="{{marketUrl}}"
target="_blank"
style="
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -377,23 +301,16 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View comment</span></span>
>View comment</span
></span
>
</a> </a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div> </div>
@ -406,9 +323,7 @@
</td> </td>
</tr> </tr>
</table> </table>
<div <div class="footer" style="
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -417,28 +332,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -448,14 +355,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center"
valign="top"
>
Questions? Come ask in Questions? Come ask in
<a <a href="https://discord.gg/eHQBNBqXuh" style="
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -463,12 +365,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -476,26 +374,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a
>.
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</td> </td>
<td <td style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top"
></td>
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -1,13 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market resolved</title> <title>Market resolved</title>
@ -33,52 +32,60 @@
body { body {
padding: 0 !important; padding: 0 !important;
} }
h1 { h1 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h2 { h2 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h3 { h3 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h4 { h4 {
font-weight: 800 !important; font-weight: 800 !important;
margin: 20px 0 5px !important; margin: 20px 0 5px !important;
} }
h1 { h1 {
font-size: 22px !important; font-size: 22px !important;
} }
h2 { h2 {
font-size: 18px !important; font-size: 18px !important;
} }
h3 { h3 {
font-size: 16px !important; font-size: 16px !important;
} }
.container { .container {
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
.content { .content {
padding: 0 !important; padding: 0 !important;
} }
.content-wrap { .content-wrap {
padding: 10px !important; padding: 10px !important;
} }
.invoice { .invoice {
width: 100% !important; width: 100% !important;
} }
} }
</style> </style>
</head> </head>
<body <body itemscope itemtype="http://schema.org/EmailMessage" style="
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
" ">
> <table class="main" width="100%" cellpadding="0" cellspacing="0" style="
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0; margin: 0;
padding: 0 0 40px 0; padding: 0 0 40px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
padding: 0; padding: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0; margin: 0;
padding: 0 0 6px 0; padding: 0 0 6px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top"
>
{{creatorName}} asked {{creatorName}} asked
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " valign="top">
valign="top" <a href="{{url}}" style="
>
<a
href="{{url}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif; 'Lucida Grande', sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9; color: #4337c9;
display: block; display: block;
text-decoration: none; text-decoration: none;
" ">
> {{question}}</a>
{{question}}</a
>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 0 0 0px; padding: 0 0 0px;
" " valign="top">
valign="top" <h2 class="aligncenter" style="
>
<h2
class="aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif; 'Lucida Grande', sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
margin: 10px 0 0; margin: 10px 0 0;
" " align="center">
align="center"
>
Resolved {{outcome}} Resolved {{outcome}}
</h2> </h2>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left; text-align: left;
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -396,138 +318,105 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top"
>
Dear {{name}}, Dear {{name}},
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
A market you bet in has been resolved! A market you bet in has been resolved!
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Your investment was Your investment was
<span style="font-weight: bold" <span style="font-weight: bold">{{investment}}</span>.
>M$ {{investment}}</span <br style="
>.
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Your payout is Your payout is
<span style="font-weight: bold" <span style="font-weight: bold">{{payout}}</span>.
>M$ {{payout}}</span <br style="
>.
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Thanks, Thanks,
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
Manifold Team Manifold Team
<br <br style="
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/> <br style="
<br
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" " />
/>
</td> </td>
</tr> </tr>
<tr <tr style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
>
<td style="padding: 10px 0 0 0; margin: 0"> <td style="padding: 10px 0 0 0; margin: 0">
<div align="center"> <div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]--> <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a <a href="{{url}}" target="_blank" style="
href="{{url}}"
target="_blank"
style="
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -545,23 +434,16 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View market</span></span>
>View market</span
></span
>
</a> </a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div> </div>
@ -574,9 +456,7 @@
</td> </td>
</tr> </tr>
</table> </table>
<div <div class="footer" style="
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -585,28 +465,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -616,14 +488,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center"
valign="top"
>
Questions? Come ask in Questions? Come ask in
<a <a href="https://discord.gg/eHQBNBqXuh" style="
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -631,12 +498,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -644,26 +507,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a
>.
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</td> </td>
<td <td style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top"
></td>
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { import {
formatLargeNumber, formatLargeNumber,
@ -53,22 +52,29 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}` const subject = `Resolved ${outcome}: ${contract.question}`
// const creatorPayoutText = const creatorPayoutText =
// userId === creator.id creatorPayout >= 1 && userId === creator.id
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)` ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
// : '' : ''
const emailType = 'market-resolved' const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const displayedInvestment =
Number.isNaN(investment) || investment < 0
? formatMoney(0)
: formatMoney(investment)
const displayedPayout = formatMoney(payout)
const templateData: market_resolved_template = { const templateData: market_resolved_template = {
userId: user.id, userId: user.id,
name: user.name, name: user.name,
creatorName: creator.name, creatorName: creator.name,
question: contract.question, question: contract.question,
outcome, outcome,
investment: `${Math.floor(investment)}`, investment: displayedInvestment,
payout: `${Math.floor(payout)}`, payout: displayedPayout + creatorPayoutText,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl, unsubscribeUrl,
} }
@ -180,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
const emailBody = `Hi ${firstName}, 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? Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience 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). 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).
@ -315,7 +321,7 @@ export const sendMarketCloseEmail = async (
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { question, slug, volume, mechanism, collectedFees } = contract const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve' const emailType = 'market-resolve'
@ -332,10 +338,6 @@ export const sendMarketCloseEmail = async (
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
creatorFee:
mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of the profits`
: formatMoney(collectedFees.creatorFee),
} }
) )
} }

View File

@ -1,33 +0,0 @@
import * as admin from 'firebase-admin'
import {
APIError,
EndpointDefinition,
lookupUser,
parseCredentials,
writeResponseError,
} from './api'
const opts = {
method: 'GET',
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
} as const
export const getcustomtoken: EndpointDefinition = {
opts,
handler: async (req, res) => {
try {
const credentials = await parseCredentials(req)
if (credentials.kind != 'jwt') {
throw new APIError(403, 'API keys cannot mint custom tokens.')
}
const user = await lookupUser(credentials)
const token = await admin.auth().createCustomToken(user.uid)
res.status(200).json({ token: token })
} catch (e) {
writeResponseError(e, res)
}
},
}

View File

@ -21,9 +21,7 @@ export * from './on-follow-user'
export * from './on-unfollow-user' export * from './on-unfollow-user'
export * from './on-create-liquidity-provision' export * from './on-create-liquidity-provision'
export * from './on-update-group' export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user' export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn' export * from './on-create-txn'
export * from './on-delete-group' export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
@ -31,6 +29,7 @@ export * from './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag' export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like'
// v2 // v2
export * from './health' export * from './health'
@ -71,7 +70,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -96,7 +95,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser) const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken) const createPostFunction = toCloudFunction(createpost)
export { export {
healthFunction as health, healthFunction as health,
@ -119,5 +118,5 @@ export {
createCheckoutSessionFunction as createcheckoutsession, createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken, createPostFunction as createpost,
} }

View File

@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
import { import {
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createNotification, createUniqueBettorBonusNotification,
} from './create-notification' } from './create-notification'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
log(`Could not find contract ${contractId}`) log(`Could not find contract ${contractId}`)
return return
} }
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId) const bettor = await getUser(bet.userId)
if (!bettor) return if (!bettor) return
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
@ -126,7 +126,7 @@ const updateBettingStreak = async (
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract, contract: Contract,
eventId: string, eventId: string,
bettorId: string bettor: User
) => { ) => {
let previousUniqueBettorIds = contract.uniqueBettorIds let previousUniqueBettorIds = contract.uniqueBettorIds
@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
) )
} }
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors // Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) { if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({ await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
} }
// No need to give a bonus for the creator's bet // No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
// Create combined txn for all new unique bettors // Create combined txn for all new unique bettors
const bonusTxnDetails = { const bonusTxnDetails = {
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else { } else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification( await createUniqueBettorBonusNotification(
contract.creatorId,
bettor,
result.txn.id, result.txn.id,
'bonus',
'created',
fromUser,
eventId + '-bonus',
result.txn.amount + '',
{
contract, contract,
slug: contract.slug, result.txn.amount,
title: contract.question, eventId + '-unique-bettor-bonus'
}
) )
} }
} }

View File

@ -6,7 +6,10 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import {
createCommentOrAnswerOrUpdatedContractNotification,
filterUserIdsForOnlyFollowerIds,
} from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
@ -60,11 +63,15 @@ export const onCreateCommentOnContract = functions
.doc(comment.betId) .doc(comment.betId)
.get() .get()
bet = betSnapshot.data() as Bet bet = betSnapshot.data() as Bet
answer = answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome) ? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined : undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
} }
const comments = await getValues<ContractComment>( const comments = await getValues<ContractComment>(
@ -95,10 +102,13 @@ export const onCreateCommentOnContract = functions
} }
) )
const recipientUserIds = uniq([ const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
uniq([
contract.creatorId, contract.creatorId,
...comments.map((comment) => comment.userId), ...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId) ]).filter((id) => id !== comment.userId),
contractId
)
await Promise.all( await Promise.all(
recipientUserIds.map((userId) => recipientUserIds.map((userId) =>

View File

@ -1,46 +0,0 @@
import * as functions from 'firebase-functions'
import { GroupComment } from '../../common/comment'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { User } from '../../common/user'
import { createGroupCommentNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore
.document('groups/{groupId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { eventId } = context
const { groupId } = context.params as {
groupId: string
}
const comment = change.data() as GroupComment
const creatorSnapshot = await firestore
.collection('users')
.doc(comment.userId)
.get()
if (!creatorSnapshot.exists) throw new Error('Could not find user')
const groupSnapshot = await firestore
.collection('groups')
.doc(groupId)
.get()
if (!groupSnapshot.exists) throw new Error('Could not find group')
const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({
mostRecentChatActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createGroupCommentNotification(
creatorSnapshot.data() as User,
memberId,
comment,
group,
eventId
)
})
)
})

View File

@ -1,28 +0,0 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Group } from '../../common/group'
export const onCreateGroup = functions.firestore
.document('groups/{groupId}')
.onCreate(async (change, context) => {
const group = change.data() as Group
const { eventId } = context
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
recipients: group.memberIds,
slug: group.slug,
title: group.name,
}
)
})

View File

@ -2,6 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent
export const onDeleteContractFollow = functions.firestore export const onDeleteContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}') .document('contracts/{contractId}/follows/{userId}')
.onDelete(async (change, context) => { .onDelete(async (change, context) => {

View File

@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return return
if (prevGroup.contractIds.length < group.contractIds.length) {
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentContractAddedTime: Date.now() })
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
}
await firestore await firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.update({ mostRecentActivityTime: Date.now() }) .update({ mostRecentActivityTime: Date.now() })
}) })
export const onCreateGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(-1),
})
})
export const onCreateGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(-1),
})
})
export async function removeGroupLinks(group: Group, contractIds: string[]) { export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) { for (const contractId of contractIds) {
const contract = await getContract(contractId) const contract = await getContract(contractId)

View File

@ -0,0 +1,109 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Like } from '../../common/like'
import { getContract, getUser, log } from './utils'
import { createLikeNotification } from './create-notification'
import { TipTxn } from '../../common/txn'
import { uniq } from 'lodash'
const firestore = admin.firestore()
export const onCreateLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onCreate(async (change, context) => {
const like = change.data() as Like
const { eventId } = context
if (like.type === 'contract') {
await handleCreateLikeNotification(like, eventId)
await updateContractLikes(like)
}
})
export const onUpdateLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onUpdate(async (change, context) => {
const like = change.after.data() as Like
const prevLike = change.before.data() as Like
const { eventId } = context
if (like.type === 'contract' && like.tipTxnId !== prevLike.tipTxnId) {
await handleCreateLikeNotification(like, eventId)
await updateContractLikes(like)
}
})
export const onDeleteLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onDelete(async (change) => {
const like = change.data() as Like
if (like.type === 'contract') {
await removeContractLike(like)
}
})
const updateContractLikes = async (like: Like) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const likedByUserIds = uniq(
(contract.likedByUserIds ?? []).concat(like.userId)
)
await firestore
.collection('contracts')
.doc(like.id)
.update({ likedByUserIds, likedByUserCount: likedByUserIds.length })
}
const handleCreateLikeNotification = async (like: Like, eventId: string) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) {
log('Could not find contract creator')
return
}
const liker = await getUser(like.userId)
if (!liker) {
log('Could not find liker')
return
}
let tipTxnData = undefined
if (like.tipTxnId) {
const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get()
if (!tipTxn.exists) {
log('Could not find tip txn')
return
}
tipTxnData = tipTxn.data() as TipTxn
}
await createLikeNotification(
liker,
contractCreator,
like,
eventId,
contract,
tipTxnData
)
}
const removeContractLike = async (like: Like) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const likedByUserIds = uniq(contract.likedByUserIds ?? [])
const newLikedByUserIds = likedByUserIds.filter(
(userId) => userId !== like.userId
)
await firestore.collection('contracts').doc(like.id).update({
likedByUserIds: newLikedByUserIds,
likedByUserCount: newLikedByUserIds.length,
})
}

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) || !isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
) { ) {
throw new APIError(400, 'Bet too large for current liquidity pool.') throw new APIError(400, 'Trade too large for current liquidity pool.')
} }
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()

View File

@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { floatingEqual } from '../../common/util/math'
export const redeemShares = async (userId: string, contractId: string) => { export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (trans) => { return await firestore.runTransaction(async (trans) => {
@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => {
const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) { if (floatingEqual(netAmount, 0)) {
return { status: 'success' } return { status: 'success' }
} }
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)

View File

@ -15,7 +15,10 @@ export const resetBettingStreaksForUsers = functions.pubsub
}) })
const resetBettingStreaksInternal = async () => { const resetBettingStreaksInternal = async () => {
const usersSnap = await firestore.collection('users').get() const usersSnap = await firestore
.collection('users')
.where('currentBettingStreak', '>', 0)
.get()
const users = usersSnap.docs.map((doc) => doc.data() as User) const users = usersSnap.docs.map((doc) => doc.data() as User)
@ -28,7 +31,7 @@ const resetBettingStreakForUser = async (user: User) => {
const betStreakResetTime = Date.now() - DAY_MS const betStreakResetTime = Date.now() - DAY_MS
// if they made a bet within the last day, don't reset their streak // if they made a bet within the last day, don't reset their streak
if ( if (
(user.lastBetTime ?? 0 > betStreakResetTime) || (user?.lastBetTime ?? 0) > betStreakResetTime ||
!user.currentBettingStreak || !user.currentBettingStreak ||
user.currentBettingStreak === 0 user.currentBettingStreak === 0
) )

View File

@ -0,0 +1,39 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, log, writeAsync } from '../utils'
import { Bet } from '../../../common/bet'
import { groupBy, mapValues, sortBy, uniq } from 'lodash'
initAdmin()
const firestore = admin.firestore()
const getBettorsByContractId = async () => {
const bets = await getValues<Bet>(firestore.collectionGroup('bets'))
log(`Loaded ${bets.length} bets.`)
const betsByContractId = groupBy(bets, 'contractId')
return mapValues(betsByContractId, (bets) =>
uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId))
)
}
const updateUniqueBettors = async () => {
const bettorsByContractId = await getBettorsByContractId()
const updates = Object.entries(bettorsByContractId).map(
([contractId, userIds]) => {
const update = {
uniqueBettorIds: userIds,
uniqueBettorCount: userIds.length,
}
const docRef = firestore.collection('contracts').doc(contractId)
return { doc: docRef, fields: update }
}
)
log(`Updating ${updates.length} contracts.`)
await writeAsync(firestore, updates)
log(`Updated all contracts.`)
}
if (require.main === module) {
updateUniqueBettors()
}

View File

@ -1,108 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, isProd } from '../utils'
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { Group, GroupLink } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { filterDefined } from 'common/util/array'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
initAdmin()
const adminFirestore = admin.firestore()
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
for (const category of categories) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', category.toLowerCase())
)
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const oldGroup = await getValues<Group>(
adminFirestore.collection('groups').where('slug', '==', slug)
)
if (oldGroup.length > 0) {
console.log(`Found old group for ${category}`)
await adminFirestore.collection('groups').doc(oldGroup[0].id).delete()
}
const allUsers = await getValues<User>(adminFirestore.collection('users'))
const groupUsers = filterDefined(
allUsers.map((user: User) => {
if (!user.followedCategories || user.followedCategories.length === 0)
return user.id
if (!user.followedCategories.includes(category.toLowerCase()))
return null
return user.id
})
)
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const newGroupRef = await adminFirestore.collection('groups').doc()
const newGroup: Group = {
id: newGroupRef.id,
name: category,
slug,
creatorId: manifoldAccount,
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Default group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
}
await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup)
// Update group with new memberIds to avoid notifying everyone
await adminFirestore
.collection('groups')
.doc(newGroupRef.id)
.update({
memberIds: uniq(groupUsers),
})
for (const market of markets) {
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: newGroup.id,
createdTime: Date.now(),
slug: newGroup.slug,
name: newGroup.name,
} as GroupLink,
]
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
groupLinks: newGroupLinks,
})
}
}
}
async function convertCategoriesToGroups() {
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
const moreCategories = ['world', 'culture']
await convertCategoriesToGroupsInternal(moreCategories)
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -4,21 +4,23 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
import { isProd, log } from '../utils' import { isProd, log } from '../utils'
import { getSlug } from '../create-group' import { getSlug } from '../create-group'
import { Group } from '../../../common/group' import { Group, GroupLink } from '../../../common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
const getTaggedContractIds = async (tag: string) => { const getTaggedContracts = async (tag: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const results = await firestore const results = await firestore
.collection('contracts') .collection('contracts')
.where('lowercaseTags', 'array-contains', tag.toLowerCase()) .where('lowercaseTags', 'array-contains', tag.toLowerCase())
.get() .get()
return results.docs.map((d) => d.id) return results.docs.map((d) => d.data() as Contract)
} }
const createGroup = async ( const createGroup = async (
name: string, name: string,
about: string, about: string,
contractIds: string[] contracts: Contract[]
) => { ) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const creatorId = isProd() const creatorId = isProd()
@ -36,21 +38,60 @@ const createGroup = async (
about, about,
createdTime: now, createdTime: now,
mostRecentActivityTime: now, mostRecentActivityTime: now,
contractIds: contractIds,
anyoneCanJoin: true, anyoneCanJoin: true,
memberIds: [], totalContracts: contracts.length,
totalMembers: 1,
} }
return await groupRef.create(group) await groupRef.create(group)
// create a GroupMemberDoc for the creator
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
await memberDoc.create({
userId: creatorId,
createdTime: now,
})
// create GroupContractDocs for each contractId
await Promise.all(
contracts
.map((c) => c.id)
.map((contractId) =>
groupRef.collection('groupContracts').doc(contractId).create({
contractId,
createdTime: now,
})
)
)
for (const market of contracts) {
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: group.id,
createdTime: Date.now(),
slug: group.slug,
name: group.name,
} as GroupLink,
]
await firestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
return { status: 'success', group: group }
} }
const convertTagToGroup = async (tag: string, groupName: string) => { const convertTagToGroup = async (tag: string, groupName: string) => {
log(`Looking up contract IDs with tag ${tag}...`) log(`Looking up contract IDs with tag ${tag}...`)
const contractIds = await getTaggedContractIds(tag) const contracts = await getTaggedContracts(tag)
log(`${contractIds.length} contracts found.`) log(`${contracts.length} contracts found.`)
if (contractIds.length > 0) { if (contracts.length > 0) {
log(`Creating group ${groupName}...`) log(`Creating group ${groupName}...`)
const about = `Contracts that used to be tagged ${tag}.` const about = `Contracts that used to be tagged ${tag}.`
const result = await createGroup(groupName, about, contractIds) const result = await createGroup(groupName, about, contracts)
log(`Done. Group: `, result) log(`Done. Group: `, result)
} }
} }

View File

@ -0,0 +1,69 @@
// Filling in the bet-based fields on comments.
import * as admin from 'firebase-admin'
import { zip } from 'lodash'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore'
initAdmin()
const firestore = admin.firestore()
async function getBetComments(transaction: Transaction) {
const allComments = await transaction.get(
firestore.collectionGroup('comments')
)
const betComments = allComments.docs.filter((d) => d.get('betId'))
log(`Found ${betComments.length} comments associated with bets.`)
return betComments
}
async function denormalize() {
let hasMore = true
while (hasMore) {
hasMore = await admin.firestore().runTransaction(async (trans) => {
const betComments = await getBetComments(trans)
const bets = await Promise.all(
betComments.map((doc) =>
trans.get(
firestore
.collection('contracts')
.doc(doc.get('contractId'))
.collection('bets')
.doc(doc.get('betId'))
)
)
)
log(`Found ${bets.length} bets associated with comments.`)
const mapping = zip(bets, betComments)
.map(([bet, comment]): DocumentCorrespondence => {
return [bet!, [comment!]] // eslint-disable-line
})
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
const diffs = amountDiffs.concat(outcomeDiffs)
diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d))
applyDiff(trans, d)
})
if (diffs.length > 500) {
console.log(`Applying first 500 because of Firestore limit...`)
}
return diffs.length > 500
})
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -0,0 +1,122 @@
import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { initAdmin } from 'functions/src/scripts/script-init'
import { log } from '../utils'
const getGroups = async () => {
const firestore = admin.firestore()
const groups = await firestore.collection('groups').get()
return groups.docs.map((doc) => doc.data() as Group)
}
// const createContractIdForGroup = async (
// groupId: string,
// contractId: string
// ) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const contractDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .get()
// if (!contractDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .create({
// contractId,
// createdTime: now,
// })
// }
// const createMemberForGroup = async (groupId: string, userId: string) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const memberDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .get()
// if (!memberDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .create({
// userId,
// createdTime: now,
// })
// }
// async function convertGroupFieldsToGroupDocuments() {
// const groups = await getGroups()
// for (const group of groups) {
// log('updating group', group.slug)
// const groupRef = admin.firestore().collection('groups').doc(group.id)
// const totalMembers = (await groupRef.collection('groupMembers').get()).size
// const totalContracts = (await groupRef.collection('groupContracts').get())
// .size
// if (
// totalMembers === group.memberIds?.length &&
// totalContracts === group.contractIds?.length
// ) {
// log('group already converted', group.slug)
// continue
// }
// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
// for (const contractId of group.contractIds?.slice(
// contractStart,
// group.contractIds?.length
// ) ?? []) {
// await createContractIdForGroup(group.id, contractId)
// }
// for (const userId of group.memberIds?.slice(
// membersStart,
// group.memberIds?.length
// ) ?? []) {
// await createMemberForGroup(group.id, userId)
// }
// }
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
const groups = await getGroups()
for (const group of groups) {
log('removing member and contract ids', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
await groupRef.update({
memberIds: admin.firestore.FieldValue.delete(),
contractIds: admin.firestore.FieldValue.delete(),
})
}
}
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
}

View File

@ -26,7 +26,7 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -65,8 +65,8 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -1,43 +1,29 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash' import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
import {
calculateCreatorVolume,
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
const firestore = admin.firestore() const firestore = admin.firestore()
const computeInvestmentValue = ( export const updateMetrics = functions
bets: Bet[], .runWith({ memory: '2GB', timeoutSeconds: 540 })
contractsDict: { [k: string]: Contract } .pubsub.schedule('every 15 minutes')
) => { .onRun(updateMetricsCore)
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT') export async function updateMetricsCore() {
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const updateMetricsCore = async () => {
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
getValues<User>(firestore.collection('users')), getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')), getValues<Contract>(firestore.collection('contracts')),
@ -55,13 +41,33 @@ export const updateMetricsCore = async () => {
const now = Date.now() const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId) const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts.map((contract) => { const contractUpdates = contracts
.filter((contract) => contract.id)
.map((contract) => {
const contractBets = betsByContract[contract.id] ?? [] const contractBets = betsByContract[contract.id] ?? []
const descendingBets = sortBy(
contractBets,
(bet) => bet.createdTime
).reverse()
let cpmmFields: Partial<CPMM> = {}
if (contract.mechanism === 'cpmm-1') {
const prob = descendingBets[0]
? descendingBets[0].probAfter
: getProbability(contract)
cpmmFields = {
prob,
probChanges: calculateProbChanges(descendingBets),
}
}
return { return {
doc: firestore.collection('contracts').doc(contract.id), doc: firestore.collection('contracts').doc(contract.id),
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
}, },
} }
}) })
@ -86,23 +92,20 @@ export const updateMetricsCore = async () => {
currentBets currentBets
) )
const lastPortfolio = last(portfolioHistory) const lastPortfolio = last(portfolioHistory)
const didProfitChange = const didPortfolioChange =
lastPortfolio === undefined || lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance || lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit( const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
portfolioHistory,
newPortfolio,
didProfitChange
)
return { return {
user, user,
newCreatorVolume, newCreatorVolume,
newPortfolio, newPortfolio,
newProfit, newProfit,
didProfitChange, didPortfolioChange,
} }
}) })
@ -118,16 +121,20 @@ export const updateMetricsCore = async () => {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map( const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { ({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: { fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
fields: { fields: {
creatorVolumeCached: newCreatorVolume, creatorVolumeCached: newCreatorVolume,
...(didProfitChange && {
profitCached: newProfit, profitCached: newProfit,
}),
nextLoanCached, nextLoanCached,
}, },
}, },
@ -138,11 +145,7 @@ export const updateMetricsCore = async () => {
.doc(user.id) .doc(user.id)
.collection('portfolioHistory') .collection('portfolioHistory')
.doc(), .doc(),
fields: { fields: didPortfolioChange ? newPortfolio : {},
...(didProfitChange && {
...newPortfolio,
}),
},
}, },
} }
} }
@ -160,108 +163,3 @@ export const updateMetricsCore = async () => {
) )
log(`Updated metrics for ${users.length} users.`) log(`Updated metrics for ${users.length} users.`)
} }
const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProfitForPeriod = (
startTime: number,
portfolioHistory: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = [...portfolioHistory]
.reverse() // so we search in descending order (most recent first), for efficiency
.find((p) => p.timestamp < startTime)
if (startingPortfolio === undefined) {
return 0
}
const startingProfit = calculateTotalProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics,
didProfitChange: boolean
) => {
if (!didProfitChange) {
return {} // early return for performance
}
const allTimeProfit = calculateTotalProfit(newPortfolio)
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
portfolioHistory,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
portfolioHistory,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
portfolioHistory,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -4,6 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { Post } from 'common/post'
export const log = (...args: unknown[]) => { export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args) console.log(`[${new Date().toISOString()}]`, ...args)
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId) return getDoc<Group>('groups', groupId)
} }
export const getPost = (postId: string) => {
return getDoc<Post>('posts', postId)
}
export const getUser = (userId: string) => { export const getUser = (userId: string) => {
return getDoc<User>('users', userId) return getDoc<User>('users', userId)
} }

View File

@ -2,10 +2,18 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getAllPrivateUsers, getUser, getValues, log } from './utils' import {
getAllPrivateUsers,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails' import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random' import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -34,7 +42,9 @@ export async function getTrendingContracts() {
async function sendTrendingMarketsEmailsToAllUsers() { async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
const privateUsers = await getAllPrivateUsers() const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => { const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return ( return (

View File

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types' import { ParsedRequest } from './types'
import { getTemplateCss } from './template-css'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getChallengeHtml(parsedReq: ParsedRequest) { export function getChallengeHtml(parsedReq: ParsedRequest) {
const { const {
@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<style> <style>
${getCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
</style> </style>
<body> <body>
<div class="px-24"> <div class="px-24">

View File

@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) {
creatorName, creatorName,
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
resolution,
// Challenge attributes: // Challenge attributes:
challengerAmount, challengerAmount,
@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) {
question: question:
getString(question) || 'Will you create a prediction market on Manifold?', getString(question) || 'Will you create a prediction market on Manifold?',
resolution: getString(resolution),
probability: getString(probability), probability: getString(probability),
numericValue: getString(numericValue) || '', numericValue: getString(numericValue) || '',
metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool', metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool',

View File

@ -0,0 +1,81 @@
import { sanitizeHtml } from './sanitizer'
export function getTemplateCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}

View File

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types' import { ParsedRequest } from './types'
import { getTemplateCss } from './template-css'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getHtml(parsedReq: ParsedRequest) { export function getHtml(parsedReq: ParsedRequest) {
const { const {
@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
numericValue, numericValue,
resolution,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100 const MAX_QUESTION_CHARS = 100
const truncatedQuestion = const truncatedQuestion =
@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) {
? question.slice(0, MAX_QUESTION_CHARS) + '...' ? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question : question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
let resolutionString = 'YES'
switch (resolution) {
case 'YES':
break
case 'NO':
resolutionColor = 'text-red-500'
resolutionString = 'NO'
break
case 'CANCEL':
resolutionColor = 'text-yellow-500'
resolutionString = 'N/A'
break
case 'MKT':
resolutionColor = 'text-blue-500'
resolutionString = numericValue ? numericValue : probability
break
}
const resolutionDiv = `
<span class='text-center ${resolutionColor}'>
<div class="text-8xl">
${resolutionString}
</div>
<div class="text-4xl">${
resolution === 'CANCEL' ? '' : 'resolved'
}</div>
</span>`
const probabilityDiv = `
<span class='text-primary text-center'>
<div class="text-8xl">${probability}</div>
<div class="text-4xl">chance</div>
</span>`
const numericValueDiv = `
<span class='text-blue-500 text-center'>
<div class="text-8xl ">${numericValue}</div>
<div class="text-4xl">expected</div>
</span>
`
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<style> <style>
${getCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
</style> </style>
<body> <body>
<div class="px-24"> <div class="px-24">
@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
<div class="text-indigo-700 text-6xl leading-tight"> <div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion} ${truncatedQuestion}
</div> </div>
<div class="flex flex-col text-primary"> <div class="flex flex-col">
<div class="text-8xl">${probability}</div> ${
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> resolution
<span class='text-blue-500 text-center'> ? resolutionDiv
<div class="text-8xl ">${ : numericValue
numericValue !== '' && probability === '' ? numericValue : '' ? numericValueDiv
}</div> : probability
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> ? probabilityDiv
</span> : ''
}
</div> </div>
</div> </div>
<!-- Metadata --> <!-- Metadata -->
<div class="absolute bottom-16"> <div class="absolute bottom-16">
<div class="text-gray-500 text-3xl"> <div class="text-gray-500 text-3xl max-w-[80vw]">
${metadata} ${metadata}
</div> </div>
</div> </div>

View File

@ -19,6 +19,7 @@ export interface ParsedRequest {
creatorName: string creatorName: string
creatorUsername: string creatorUsername: string
creatorAvatarUrl: string creatorAvatarUrl: string
resolution: string
// Challenge attributes: // Challenge attributes:
challengerAmount: string challengerAmount: string
challengerOutcome: string challengerOutcome: string

View File

@ -8,20 +8,22 @@
"web" "web"
], ],
"scripts": { "scripts": {
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)",
"lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"@types/node": "16.11.11", "@types/node": "16.11.11",
"@typescript-eslint/eslint-plugin": "5.36.0",
"@typescript-eslint/parser": "5.36.0",
"concurrently": "6.5.1", "concurrently": "6.5.1",
"eslint": "8.15.0", "eslint": "8.23.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0", "eslint-plugin-unused-imports": "^2.0.0",
"typescript": "4.6.4", "nodemon": "2.0.19",
"prettier": "2.7.1",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"nodemon": "2.0.19" "typescript": "4.8.2"
}, },
"resolutions": { "resolutions": {
"@types/react": "17.0.43" "@types/react": "17.0.43"

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['lodash'], plugins: ['lodash', 'unused-imports'],
extends: [ extends: [
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended', 'plugin:react-hooks/recommended',
@ -22,6 +22,7 @@ module.exports = {
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error',
}, },
env: { env: {
browser: true, browser: true,

View File

@ -1,38 +0,0 @@
import clsx from 'clsx'
import { useState, ReactNode } from 'react'
export function AdvancedPanel(props: { children: ReactNode }) {
const { children } = props
const [collapsed, setCollapsed] = useState(true)
return (
<div
tabIndex={0}
className={clsx(
'collapse collapse-arrow relative',
collapsed ? 'collapse-close' : 'collapse-open'
)}
>
<div
onClick={() => setCollapsed((collapsed) => !collapsed)}
className="cursor-pointer"
>
<div className="mt-4 mr-6 text-right text-sm text-gray-500">
Advanced
</div>
<div
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
style={{
top: -2,
right: -15,
color: '#6a7280' /* gray-500 */,
}}
/>
</div>
<div className="collapse-content m-0 !bg-transparent !p-0">
{children}
</div>
</div>
)
}

View File

@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void setError: (error: string | undefined) => void
minimumAmount?: number minimumAmount?: number
disabled?: boolean disabled?: boolean
showSliderOnMobile?: boolean
className?: string className?: string
inputClassName?: string inputClassName?: string
// Needed to focus the amount input // Needed to focus the amount input
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
onChange, onChange,
error, error,
setError, setError,
showSliderOnMobile: showSlider,
disabled, disabled,
className, className,
inputClassName, inputClassName,
@ -121,6 +123,7 @@ export function BuyAmountInput(props: {
} }
return ( return (
<>
<AmountInput <AmountInput
amount={amount} amount={amount}
onChange={onAmountChange} onChange={onAmountChange}
@ -131,5 +134,17 @@ export function BuyAmountInput(props: {
inputClassName={inputClassName} inputClassName={inputClassName}
inputRef={inputRef} inputRef={inputRef}
/> />
{showSlider && (
<input
type="range"
min="0"
max="200"
value={amount ?? 0}
onChange={(e) => onAmountChange(parseInt(e.target.value))}
className="range range-lg z-40 mb-2 xl:hidden"
step="5"
/>
)}
</>
) )
} }

View File

@ -1,4 +1,5 @@
import { Point, ResponsiveLine } from '@nivo/line' import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { zip } from 'lodash' import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
@ -26,8 +27,10 @@ export function DailyCountChart(props: {
return ( return (
<div <div
className="w-full overflow-hidden" className={clsx(
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} 'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}
@ -78,8 +81,10 @@ export function DailyPercentChart(props: {
return ( return (
<div <div
className="w-full overflow-hidden" className={clsx(
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} 'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
@ -24,7 +24,7 @@ import {
} from 'common/calculate-dpm' } from 'common/calculate-dpm'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device' import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box' import { AlertBox } from '../alert-box'
@ -120,7 +120,7 @@ export function AnswerBetPanel(props: {
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch"> <Row className="items-center justify-between self-stretch">
<div className="text-xl"> <div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'} Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div> </div>
{!isModal && ( {!isModal && (
@ -132,7 +132,11 @@ export function AnswerBetPanel(props: {
</button> </button>
)} )}
</Row> </Row>
<div className="my-3 text-left text-sm text-gray-500">Amount </div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -141,6 +145,7 @@ export function AnswerBetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
showSliderOnMobile
/> />
{(betAmount ?? 0) > 10 && {(betAmount ?? 0) > 10 &&
@ -201,10 +206,10 @@ export function AnswerBetPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit trade'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
) : ( ) : (
<SignUpPrompt /> <BetSignUpPrompt />
)} )}
</Col> </Col>
) )

View File

@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}) { }) {
const { contract, bets, height } = props const { contract, bets, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract const { createdTime, resolutionTime, closeTime, answers } = contract
const now = Date.now()
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
bets, bets,
contract contract
) )
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs( const latestTime = dayjs(
resolutionTime && isClosed resolutionTime && isClosed
? Math.min(resolutionTime, closeTime) ? Math.min(resolutionTime, closeTime)
: isClosed : isClosed
? closeTime ? closeTime
: resolutionTime ?? Date.now() : resolutionTime ?? now
) )
const { width } = useWindowSize() const { width } = useWindowSize()
@ -71,13 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100] const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2 const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours') const startDate = dayjs(contract.createdTime)
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? new Date(contract.createdTime) ? latestTime.add(1, 'hours')
: hoursAgo.toDate() : latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
return ( return (
<div <div
@ -95,17 +97,25 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}} }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: startDate, min: startDate.toDate(),
max: latestTime.toDate(), max: endDate.toDate(),
}} }}
xFormat={(d) => xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
} }
axisBottom={{ axisBottom={{
tickValues: numXTickValues, tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}} }}
colors={{ scheme: 'pastel1' }} colors={[
'#fca5a5', // red-300
'#a5b4fc', // indigo-300
'#86efac', // green-300
'#fef08a', // yellow-200
'#fdba74', // orange-300
'#c084fc', // purple-400
]}
pointSize={0} pointSize={0}
curve="stepAfter" curve="stepAfter"
enableSlices="x" enableSlices="x"
@ -149,19 +159,20 @@ function formatPercent(y: DatumValue) {
} }
function formatTime( function formatTime(
now: number,
time: number, time: number,
includeYear: boolean, includeYear: boolean,
includeHour: boolean, includeHour: boolean,
includeMinute: boolean includeMinute: boolean
) { ) {
const d = dayjs(time) const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' return 'Now'
let format: string let format: string
if (d.isSame(Date.now(), 'day')) { if (d.isSame(now, 'day')) {
format = '[Today]' format = '[Today]'
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) { } else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]' format = '[Yesterday]'
} else { } else {
format = 'MMM D' format = 'MMM D'

View File

@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel' import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel' import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { ActivityItem } from '../feed/activity-items'
import { User } from 'common/user' import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
@ -21,9 +20,9 @@ import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector' import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
@ -176,7 +175,6 @@ function getAnswerItems(
type: 'answer' as const, type: 'answer' as const,
contract, contract,
answer, answer,
items: [] as ActivityItem[],
user, user,
} }
}) })
@ -186,7 +184,6 @@ function getAnswerItems(
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
items: ActivityItem[]
type: string type: string
}) { }) {
const { answer, contract } = props const { answer, contract } = props

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
@ -25,6 +25,7 @@ import { Bet } from 'common/bet'
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
import { Button } from '../button'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -115,9 +116,11 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%' const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Col className="flex-1 gap-2"> <Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div> <div className="mb-1">Add your answer</div>
<Textarea <Textarea
value={text} value={text}
@ -146,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
{text && ( {text && (
<> <>
<Col className="mt-1 gap-2"> <Col className="mt-1 gap-2">
<div className="text-sm text-gray-500">Bet amount</div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>{' '}
<BuyAmountInput <BuyAmountInput
amount={betAmount} amount={betAmount}
onChange={setBetAmount} onChange={setBetAmount}
@ -154,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setError={setAmountError} setError={setAmountError}
minimumAmount={1} minimumAmount={1}
disabled={isSubmitting} disabled={isSubmitting}
showSliderOnMobile
/> />
</Col> </Col>
<Col className="gap-3"> <Col className="gap-3">
@ -197,16 +206,18 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
disabled={!canSubmit} disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')} onClick={withTracking(submitAnswer, 'submit answer')}
> >
Submit answer & buy Submit
</button> </button>
) : ( ) : (
text && ( text && (
<button <Button
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" color="green"
size="lg"
className="self-end whitespace-nowrap "
onClick={withTracking(firebaseLogin, 'answer panel sign in')} onClick={withTracking(firebaseLogin, 'answer panel sign in')}
> >
Sign in Add my answer
</button> </Button>
) )
)} )}
</Col> </Col>

View File

@ -0,0 +1,141 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { useMemberGroups } from 'web/hooks/use-group'
import { filterDefined } from 'common/util/array'
import { keyBy } from 'lodash'
import { User } from 'common/user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: {
visible: string[]
hidden: string[]
}) => void
}) {
const { user, homeSections, setHomeSections } = props
const groups = useMemberGroups(user?.id) ?? []
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
groups,
homeSections
)
return (
<DragDropContext
onDragEnd={(e) => {
const { destination, source, draggableId } = e
if (!destination) return
const item = itemsById[draggableId]
const newHomeSections = {
visible: visibleItems.map((item) => item.id),
hidden: hiddenItems.map((item) => item.id),
}
const sourceSection = source.droppableId as 'visible' | 'hidden'
newHomeSections[sourceSection].splice(source.index, 1)
const destSection = destination.droppableId as 'visible' | 'hidden'
newHomeSections[destSection].splice(destination.index, 0, item.id)
setHomeSections(newHomeSections)
}}
>
<Row className="relative max-w-lg gap-4">
<DraggableList items={visibleItems} title="Visible" />
<DraggableList items={hiddenItems} title="Hidden" />
</Row>
</DragDropContext>
)
}
function DraggableList(props: {
title: string
items: { id: string; label: string }[]
}) {
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
{(provided, snapshot) => (
<Col
{...provided.droppableProps}
ref={provided.innerRef}
className={clsx(
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
snapshot.isDraggingOver && 'bg-gray-100'
)}
>
<Subtitle text={title} className="mx-2 !my-2" />
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={provided.draggableProps.style}
className={clsx(
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
snapshot.isDragging && 'z-[9000] bg-gray-300'
)}
>
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</Col>
)}
</Droppable>
)
}
export const getHomeItems = (
groups: Group[],
homeSections: { visible: string[]; hidden: string[] }
) => {
const items = [
{ label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' },
{ label: 'Your trades', id: 'your-bets' },
...groups.map((g) => ({
label: g.name,
id: g.id,
})),
]
const itemsById = keyBy(items, 'id')
const { visible, hidden } = homeSections
const [visibleItems, hiddenItems] = [
filterDefined(visible.map((id) => itemsById[id])),
filterDefined(hidden.map((id) => itemsById[id])),
]
// Add unmentioned items to the visible list.
visibleItems.push(
...items.filter(
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
)
)
return {
visibleItems,
hiddenItems,
itemsById,
}
}

View File

@ -8,18 +8,30 @@ import {
getUserAndPrivateUser, getUserAndPrivateUser,
setCachedReferralInfoForUser, setCachedReferralInfoForUser,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api' import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random' import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know // Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in. // the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage =
typeof window !== 'undefined'
? window.localStorage
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
const ensureDeviceToken = () => { const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token') let deviceToken = localStorage.getItem('device-token')
if (!deviceToken) { if (!deviceToken) {
@ -29,6 +41,16 @@ const ensureDeviceToken = () => {
return deviceToken return deviceToken
} }
export const setUserCookie = (cookie: string | undefined) => {
const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [
['path', '/'],
['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()],
['samesite', 'lax'],
['secure'],
])
document.cookie = data
}
export const AuthContext = createContext<AuthUser>(undefined) export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider(props: { export function AuthProvider(props: {
@ -46,55 +68,67 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser]) }, [setAuthUser, serverUser])
useEffect(() => { useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => { if (authUser != null) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])
useEffect(() => {
return onIdTokenChanged(
auth,
async (fbUser) => {
if (fbUser) { if (fbUser) {
setTokenCookies({ setUserCookie(JSON.stringify(fbUser.toJSON()))
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
let current = await getUserAndPrivateUser(fbUser.uid) let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) { if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken() const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser current = (await createUser({ deviceToken })) as UserAndPrivateUser
setCachedReferralInfoForUser(current.user)
} }
setAuthUser(current) setAuthUser(current)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else { } else {
// User logged out; reset to null // User logged out; reset to null
deleteTokenCookies() setUserCookie(undefined)
setAuthUser(null) setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
} }
}) },
(e) => {
console.error(e)
}
)
}, [setAuthUser]) }, [setAuthUser])
const uid = authUser?.user.id const uid = authUser?.user.id
const username = authUser?.user.username
useEffect(() => { useEffect(() => {
if (uid && username) { if (uid) {
identifyUser(uid) identifyUser(uid)
setUserProperty('username', username) const userListener = listenForUser(uid, (user) => {
const userListener = listenForUser(uid, (user) => setAuthUser((currAuthUser) =>
setAuthUser((authUser) => { currAuthUser && user ? { ...currAuthUser, user } : null
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, user: user! }
})
) )
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, privateUser: privateUser! }
}) })
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((currAuthUser) =>
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
)
}) })
return () => { return () => {
userListener() userListener()
privateUserListener() privateUserListener()
} }
} }
}, [uid, username, setAuthUser]) }, [uid, setAuthUser])
const username = authUser?.user.username
useEffect(() => {
if (username != null) {
setUserProperty('username', username)
}
}, [username])
return ( return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -2,6 +2,7 @@ import Router from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { MouseEvent, useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
import Image from 'next/future/image'
export function Avatar(props: { export function Avatar(props: {
username?: string username?: string
@ -14,6 +15,7 @@ export function Avatar(props: {
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4
const onClick = const onClick =
noLink && username noLink && username
@ -26,7 +28,9 @@ export function Avatar(props: {
// there can be no avatar URL or username in the feed, we show a "submit comment" // there can be no avatar URL or username in the feed, we show a "submit comment"
// item with a fake grey user circle guy even if you aren't signed in // item with a fake grey user circle guy even if you aren't signed in
return avatarUrl ? ( return avatarUrl ? (
<img <Image
width={sizeInPx}
height={sizeInPx}
className={clsx( className={clsx(
'flex-shrink-0 rounded-full bg-white object-cover', 'flex-shrink-0 rounded-full bg-white object-cover',
`w-${s} h-${s}`, `w-${s} h-${s}`,

View File

@ -9,7 +9,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { firebaseLogin } from 'web/lib/firebase/users' import { BetSignUpPrompt } from './sign-up-prompt'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -32,15 +32,20 @@ export default function BetButton(props: {
return ( return (
<> <>
<Col className={clsx('items-center', className)}> <Col className={clsx('items-center', className)}>
{user ? (
<Button <Button
size={'lg'} size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} className={clsx(
onClick={() => { 'my-auto inline-flex min-w-[75px] whitespace-nowrap',
!user ? firebaseLogin() : setOpen(true) btnClassName
}} )}
onClick={() => setOpen(true)}
> >
{user ? 'Bet' : 'Sign up to Bet'} Predict
</Button> </Button>
) : (
<BetSignUpPrompt />
)}
{user && ( {user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}>

View File

@ -12,7 +12,7 @@ import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm' import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col' import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
@ -112,7 +112,7 @@ export function BetInline(props: {
: 'Submit'} : 'Submit'}
</Button> </Button>
)} )}
<SignUpPrompt size="xs" /> <BetSignUpPrompt size="xs" />
<button <button
onClick={() => { onClick={() => {
setProbAfter(undefined) setProbAfter(undefined)

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { clamp, partition, sumBy } from 'lodash' import { clamp, partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
@ -8,6 +8,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { import {
formatLargeNumber,
formatMoney, formatMoney,
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
@ -28,11 +29,10 @@ import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus' import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric' import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityOrNumericInput } from './probability-input' import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
@ -68,6 +68,8 @@ export function BetPanel(props: {
className className
)} )}
> >
{user ? (
<>
<QuickOrLimitBet <QuickOrLimitBet
isLimitOrder={isLimitOrder} isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder} setIsLimitOrder={setIsLimitOrder}
@ -85,10 +87,13 @@ export function BetPanel(props: {
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
/> />
</>
<SignUpPrompt /> ) : (
<>
{!user && <PlayMoneyDisclaimer />} <BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
)}
</Col> </Col>
{user && unfilledBets.length > 0 && ( {user && unfilledBets.length > 0 && (
@ -146,7 +151,7 @@ export function SimpleBetPanel(props: {
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<SignUpPrompt /> <BetSignUpPrompt />
{!user && <PlayMoneyDisclaimer />} {!user && <PlayMoneyDisclaimer />}
</Col> </Col>
@ -179,12 +184,12 @@ function BuyPanel(props: {
const [inputRef, focusAmountInput] = useFocus() const [inputRef, focusAmountInput] = useFocus()
useEffect(() => { // useEffect(() => {
if (selected) { // if (selected) {
if (isIOS()) window.scrollTo(0, window.scrollY + 200) // if (isIOS()) window.scrollTo(0, window.scrollY + 200)
focusAmountInput() // focusAmountInput()
} // }
}, [selected, focusAmountInput]) // }, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice) setOutcome(choice)
@ -252,17 +257,43 @@ function BuyPanel(props: {
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame = const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb) formatPercent(resultProb) === formatPercent(initialProb)
const probChange = Math.abs(resultProb - initialProb) const probChange = Math.abs(resultProb - initialProb)
const currentPayout = newBet.shares const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn) const currentReturnPercent = formatPercent(currentReturn)
const format = getFormattedMappedValue(contract) const format = getFormattedMappedValue(contract)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference = isPseudoNumeric
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market by ${displayedDifference}?`}
/>
) : (
<></>
)
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500"> <div className="my-3 text-left text-sm text-gray-500">
@ -276,7 +307,13 @@ function BuyPanel(props: {
isPseudoNumeric={isPseudoNumeric} isPseudoNumeric={isPseudoNumeric}
/> />
<div className="my-3 text-left text-sm text-gray-500">Amount</div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -285,35 +322,10 @@ function BuyPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
showSliderOnMobile
/> />
{(betAmount ?? 0) > 10 && {warning}
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (
''
)}
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market ${
isPseudoNumeric && contract.isLogScale
? 'this much'
: format(probChange)
}?`}
/>
) : (
''
)}
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
@ -342,9 +354,6 @@ function BuyPanel(props: {
</> </>
)} )}
</div> </div>
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/> */}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">
@ -370,11 +379,11 @@ function BuyPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit bet'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col> </Col>
) )
} }
@ -560,7 +569,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -571,7 +580,7 @@ function LimitOrderPanel(props: {
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -594,9 +603,15 @@ function LimitOrderPanel(props: {
</div> </div>
)} )}
<div className="mt-1 mb-3 text-left text-sm text-gray-500"> <Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
<span>
Max amount<span className="ml-1 text-red-500">*</span> Max amount<span className="ml-1 text-red-500">*</span>
</div> </span>
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -604,6 +619,7 @@ function LimitOrderPanel(props: {
error={error} error={error}
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
showSliderOnMobile
/> />
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
@ -734,15 +750,16 @@ function QuickOrLimitBet(props: {
return ( return (
<Row className="align-center mb-4 justify-between"> <Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div> <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
{!hideToggle && ( {!hideToggle && (
<Row className="mt-1 items-center gap-2"> <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton <PillButton
selected={!isLimitOrder} selected={!isLimitOrder}
onSelect={() => { onSelect={() => {
setIsLimitOrder(false) setIsLimitOrder(false)
track('select quick order') track('select quick order')
}} }}
xs={true}
> >
Quick Quick
</PillButton> </PillButton>
@ -752,6 +769,7 @@ function QuickOrLimitBet(props: {
setIsLimitOrder(true) setIsLimitOrder(true)
track('select limit order') track('select limit order')
}} }}
xs={true}
> >
Limit Limit
</PillButton> </PillButton>

View File

@ -1,23 +1,13 @@
import Link from 'next/link' import Link from 'next/link'
import { import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
Dictionary,
keyBy,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
uniq,
} from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { import {
formatLargeNumber,
formatMoney, formatMoney,
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
@ -28,10 +18,8 @@ import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { UserLink } from './user-page'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
@ -46,8 +34,6 @@ import {
resolvedPayout, resolvedPayout,
getContractBetNullMetrics, getContractBetNullMetrics,
} from 'common/calculate' } from 'common/calculate'
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
@ -56,9 +42,10 @@ import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math' import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -72,26 +59,22 @@ export function BetsList(props: { user: User }) {
const signedInUser = useUser() const signedInUser = useUser()
const isYourBets = user.id === signedInUser?.id const isYourBets = user.id === signedInUser?.id
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022 const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
const userBets = useUserBets(user.id, { includeRedemptions: true }) const userBets = useUserBets(user.id)
const [contractsById, setContractsById] = useState<
Dictionary<Contract> | undefined
>()
// Hide bets before 06-01-2022 if this isn't your own profile // Hide bets before 06-01-2022 if this isn't your own profile
// NOTE: This means public profits also begin on 06-01-2022 as well. // NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo( const bets = useMemo(
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), () =>
userBets?.filter(
(bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0)
),
[userBets, hideBetsBefore] [userBets, hideBetsBefore]
) )
useEffect(() => { const contractList = useUserBetContracts(user.id)
if (bets) { const contractsById = useMemo(() => {
const contractIds = uniq(bets.map((b) => b.contractId)) return contractList ? keyBy(contractList, 'id') : undefined
Promise.all(contractIds.map(getContractFromId)).then((contracts) => { }, [contractList])
setContractsById(keyBy(filterDefined(contracts), 'id'))
})
}
}, [bets])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open') const [filter, setFilter] = useState<BetFilter>('open')
@ -99,13 +82,6 @@ export function BetsList(props: { user: User }) {
const start = page * CONTRACTS_PER_PAGE const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) { if (!bets || !contractsById) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
@ -185,7 +161,7 @@ export function BetsList(props: { user: User }) {
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return ( return (
<Col className="mt-6"> <Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> <Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
<Row className="gap-8"> <Row className="gap-8">
<Col> <Col>
@ -233,9 +209,10 @@ export function BetsList(props: { user: User }) {
<Col className="mt-6 divide-y"> <Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? ( {displayedContracts.length === 0 ? (
<NoBets user={user} /> <NoMatchingBets />
) : ( ) : (
displayedContracts.map((contract) => ( <>
{displayedContracts.map((contract) => (
<ContractBets <ContractBets
key={contract.id} key={contract.id}
contract={contract} contract={contract}
@ -243,16 +220,16 @@ export function BetsList(props: { user: User }) {
metric={sort === 'profit' ? 'profit' : 'value'} metric={sort === 'profit' ? 'profit' : 'value'}
isYourBets={isYourBets} isYourBets={isYourBets}
/> />
)) ))}
)}
</Col>
<Pagination <Pagination
page={page} page={page}
itemsPerPage={CONTRACTS_PER_PAGE} itemsPerPage={CONTRACTS_PER_PAGE}
totalItems={filteredContracts.length} totalItems={filteredContracts.length}
setPage={setPage} setPage={setPage}
/> />
</>
)}
</Col>
</Col> </Col>
) )
} }
@ -260,7 +237,7 @@ export function BetsList(props: { user: User }) {
const NoBets = ({ user }: { user: User }) => { const NoBets = ({ user }: { user: User }) => {
const me = useUser() const me = useUser()
return ( return (
<div className="mx-4 text-gray-500"> <div className="mx-4 py-4 text-gray-500">
{user.id === me?.id ? ( {user.id === me?.id ? (
<> <>
You have not made any bets yet.{' '} You have not made any bets yet.{' '}
@ -274,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => {
</div> </div>
) )
} }
const NoMatchingBets = () => (
<div className="mx-4 py-4 text-gray-500">
No bets matching the current filter.
</div>
)
function ContractBets(props: { function ContractBets(props: {
contract: Contract contract: Contract
@ -405,19 +387,16 @@ export function BetsSummary(props: {
const isClosed = closeTime && Date.now() > closeTime const isClosed = closeTime && Date.now() > closeTime
const bets = props.bets.filter((b) => !b.isAnte) const bets = props.bets.filter((b) => !b.isAnte)
const { hasShares } = getContractBetMetrics(contract, bets) const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSalesAndAntes = bets.filter( const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
(b) => !b.isAnte && !b.isSold && !b.sale const yesWinnings = sumBy(excludeSales, (bet) =>
)
const yesWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'YES') calculatePayout(contract, bet, 'YES')
) )
const noWinnings = sumBy(excludeSalesAndAntes, (bet) => const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO') calculatePayout(contract, bet, 'NO')
) )
const { invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const user = useUser() const user = useUser()
@ -500,27 +479,10 @@ export function BetsSummary(props: {
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col> </Col>
</> </>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : ( ) : (
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
Current value Expected value
</div> </div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div> <div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col> </Col>

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
@ -14,7 +14,7 @@ export type ColorType =
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: () => void onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType
@ -37,8 +37,8 @@ export function Button(props: {
sm: 'px-3 py-2 text-sm', sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm', md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base', lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base', xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl', '2xl': 'px-6 py-3 text-xl font-semibold',
}[size] }[size]
return ( return (
@ -52,9 +52,9 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
color === 'gradient' && color === 'gradient' &&
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
className className

View File

@ -5,19 +5,19 @@ export function PillButton(props: {
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
color?: string color?: string
big?: boolean xs?: boolean
children: ReactNode children: ReactNode
}) { }) {
const { children, selected, onSelect, color, big } = props const { children, selected, onSelect, color, xs } = props
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm',
xs ? 'text-xs' : '',
selected selected
? ['text-white', color ?? 'bg-greyscale-6'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3', : 'bg-greyscale-2 hover:bg-greyscale-3'
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)} )}
onClick={onSelect} onClick={onSelect}
> >

View File

@ -0,0 +1,72 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { throttle } from 'lodash'
import { ReactNode, useRef, useState, useEffect } from 'react'
import { Row } from './layout/row'
import { VisibilityObserver } from 'web/components/visibility-observer'
export function Carousel(props: {
children: ReactNode
loadMore?: () => void
className?: string
}) {
const { children, loadMore, className } = props
const ref = useRef<HTMLDivElement>(null)
const th = (f: () => any) => throttle(f, 500, { trailing: false })
const scrollLeft = th(() =>
ref.current?.scrollBy({ left: -ref.current.clientWidth })
)
const scrollRight = th(() =>
ref.current?.scrollBy({ left: ref.current.clientWidth })
)
const [atFront, setAtFront] = useState(true)
const [atBack, setAtBack] = useState(false)
const onScroll = throttle(() => {
if (ref.current) {
const { scrollLeft, clientWidth, scrollWidth } = ref.current
setAtFront(scrollLeft < 80)
setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80)
}
}, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, [children])
return (
<div className={clsx('relative', className)}>
<Row
className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
ref={ref}
onScroll={onScroll}
>
{children}
{loadMore && (
<VisibilityObserver
className="relative -left-96"
onVisibilityUpdated={(visible) => visible && loadMore()}
/>
)}
</Row>
{!atFront && (
<div
className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
onMouseDown={scrollLeft}
>
<ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
</div>
)}
{!atBack && (
<div
className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
onMouseDown={scrollRight}
>
<ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
</div>
)}
</div>
)
}

View File

@ -2,7 +2,7 @@ import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge' import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { SignUpPrompt } from 'web/components/sign-up-prompt' import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { acceptChallenge, APIError } from 'web/lib/firebase/api' import { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
setErrorText('') setErrorText('')
}, [open]) }, [open])
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" /> if (!user)
return <BetSignUpPrompt label="Sign up to accept" className="mt-4" />
const iAcceptChallenge = () => { const iAcceptChallenge = () => {
setLoading(true) setLoading(true)

View File

@ -147,7 +147,7 @@ function CreateChallengeForm(props: {
setFinishedCreating(true) setFinishedCreating(true)
}} }}
> >
<Title className="!mt-2" text="Challenge bet " /> <Title className="!mt-2 hidden sm:block" text="Challenge bet " />
<div className="mb-8"> <div className="mb-8">
Challenge a friend to bet on{' '} Challenge a friend to bet on{' '}
@ -170,7 +170,8 @@ function CreateChallengeForm(props: {
)} )}
</div> </div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> <Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
<Col>
<div>You'll bet:</div> <div>You'll bet:</div>
<Row <Row
className={ className={
@ -213,7 +214,9 @@ function CreateChallengeForm(props: {
</Button> </Button>
</Row> </Row>
<Row className={'items-center'}>If they bet:</Row> <Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> <Row
className={'max-w-xs items-center justify-between gap-4 pr-3'}
>
<div className={'w-32 sm:mr-1'}> <div className={'w-32 sm:mr-1'}>
<AmountInput <AmountInput
amount={challengeInfo.acceptorAmount || undefined} amount={challengeInfo.acceptorAmount || undefined}
@ -235,7 +238,8 @@ function CreateChallengeForm(props: {
<span>on</span> <span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row> </Row>
</div> </Col>
</Col>
{contract && ( {contract && (
<Button <Button
size="2xs" size="2xs"

View File

@ -1,9 +1,9 @@
import { DonationTxn } from 'common/txn' import { DonationTxn } from 'common/txn'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import { UserLink } from '../user-page'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { RelativeTimestamp } from '../relative-timestamp' import { RelativeTimestamp } from '../relative-timestamp'
import { UserLink } from 'web/components/user-link'
export function Donation(props: { txn: DonationTxn }) { export function Donation(props: { txn: DonationTxn }) {
const { txn } = props const { txn } = props

View File

@ -0,0 +1,175 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -1,20 +1,23 @@
import { useEffect, useState } from 'react' import { ContractComment } from 'common/comment'
import { Comment, ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array' import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments' import { getUserCommentsQuery } from 'web/lib/firebase/comments'
import { usePagination } from 'web/hooks/use-pagination'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { RelativeTimestamp } from './relative-timestamp' import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page'
import { User } from 'common/user' import { User } from 'common/user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Content } from './editor' import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { UserLink } from 'web/components/user-link'
import { PaginationNextPrev } from 'web/components/pagination'
const COMMENTS_PER_PAGE = 50 type ContractKey = {
contractId: string
contractSlug: string
contractQuestion: string
}
function contractPath(slug: string) { function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't // by convention this includes the contract creator username, but we don't
@ -24,67 +27,83 @@ function contractPath(slug: string) {
export function UserCommentsList(props: { user: User }) { export function UserCommentsList(props: { user: User }) {
const { user } = props const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
useEffect(() => { const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
getUsersComments(user.id).then((cs) => { const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
// we don't show comments in groups here atm, just comments on contracts
setComments( const pageComments = groupConsecutive(getItems(), (c) => {
cs.filter((c) => c.commentType == 'contract') as ContractComment[] return {
) contractId: c.contractId,
contractQuestion: c.contractQuestion,
contractSlug: c.contractSlug,
}
}) })
}, [user.id])
if (comments == null) { if (isLoading) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
const pageComments = groupConsecutive(comments.slice(start, end), (c) => { if (pageComments.length === 0) {
return { question: c.contractQuestion, slug: c.contractSlug } if (isStart && isEnd) {
}) return <p>This user hasn't made any comments yet.</p>
} else {
// this can happen if their comment count is a multiple of page size
return <p>No more comments to display.</p>
}
}
return ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => { {pageComments.map(({ key, items }, i) => {
return ( return <ProfileCommentGroup key={i} groupKey={key} items={items} />
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
>
{key.question}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
))}
</Col>
</div>
)
})} })}
<Pagination <nav
page={page} className="border-t border-gray-200 px-4 py-3 sm:px-6"
itemsPerPage={COMMENTS_PER_PAGE} aria-label="Pagination"
totalItems={comments.length} >
setPage={setPage} <PaginationNextPrev
prev={!isStart ? 'Previous' : null}
next={!isEnd ? 'Next' : null}
onClickPrev={getPrev}
onClickNext={getNext}
scrollToTop={true}
/> />
</nav>
</Col> </Col>
) )
} }
function ProfileComment(props: { comment: Comment; className?: string }) { function ProfileCommentGroup(props: {
const { comment, className } = props groupKey: ContractKey
items: ContractComment[]
}) {
const { groupKey, items } = props
const { contractSlug, contractQuestion } = groupKey
const path = contractPath(contractSlug)
return (
<div className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={path}
>
{contractQuestion}
</SiteLink>
<Col className="gap-6">
{items.map((c) => (
<ProfileComment key={c.id} comment={c} />
))}
</Col>
</div>
)
}
function ProfileComment(props: { comment: ContractComment }) {
const { comment } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment comment
// TODO: find and attach relevant bets by comment betId at some point // TODO: find and attach relevant bets by comment betId at some point
return ( return (
<Row className={className}> <Row className="relative flex items-start space-x-3">
<Avatar username={userUsername} avatarUrl={userAvatarUrl} /> <Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500"> <p className="mt-0.5 text-sm text-gray-500">

View File

@ -1,44 +1,35 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch, { SearchIndex } from 'algoliasearch/lite' import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search' import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
} from './contract/contracts-grid' } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useRef, useMemo, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
import { unstable_batchedUpdates } from 'react-dom'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import {
storageStore,
historyStore,
urlParamStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { track, trackCallback } from 'web/lib/service/analytics' import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group' import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { debounce, sortBy } from 'lodash' import { debounce, isEqual, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col' import { Col } from './layout/col'
import { safeLocalStorage } from 'web/lib/util/local'
import clsx from 'clsx' import clsx from 'clsx'
// TODO: this obviously doesn't work with SSR, common sense would suggest
// that we should save things like this in cookies so the server has them
const MARKETS_SORT = 'markets_sort'
function setSavedSort(s: Sort) {
safeLocalStorage()?.setItem(MARKETS_SORT, s)
}
function getSavedSort() {
return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined
}
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3' '75c28fc084a80e1129d427d470cf41a3'
@ -47,25 +38,29 @@ const searchClient = algoliasearch(
const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const sortOptions = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' }, { label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' }, { label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' }, { label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
] { label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const
export type Sort = typeof SORTS[number]['value']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = { type SearchParameters = {
index: SearchIndex
query: string query: string
numericFilters: SearchOptions['numericFilters'] sort: Sort
openClosedFilter: 'open' | 'closed' | undefined
facetFilters: SearchOptions['facetFilters'] facetFilters: SearchOptions['facetFilters']
showTime?: ShowTime
} }
type AdditionalFilter = { type AdditionalFilter = {
@ -73,6 +68,8 @@ type AdditionalFilter = {
tag?: string tag?: string
excludeContractIds?: string[] excludeContractIds?: string[]
groupSlug?: string groupSlug?: string
yourBets?: boolean
followed?: boolean
} }
export function ContractSearch(props: { export function ContractSearch(props: {
@ -88,11 +85,15 @@ export function ContractSearch(props: {
hideQuickBet?: boolean hideQuickBet?: boolean
} }
headerClassName?: string headerClassName?: string
useQuerySortLocalStorage?: boolean persistPrefix?: string
useQuerySortUrlParams?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
maxItems?: number
noControls?: boolean noControls?: boolean
maxResults?: number
renderContracts?: (
contracts: Contract[] | undefined,
loadMore: () => void
) => ReactNode
}) { }) {
const { const {
user, user,
@ -104,66 +105,95 @@ export function ContractSearch(props: {
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
headerClassName, headerClassName,
useQuerySortLocalStorage, persistPrefix,
useQuerySortUrlParams, useQueryUrlParam,
isWholePage, isWholePage,
maxItems,
noControls, noControls,
maxResults,
renderContracts,
} = props } = props
const [numPages, setNumPages] = useState(1) const [state, setState] = usePersistentState(
const [pages, setPages] = useState<Contract[][]>([]) {
const [showTime, setShowTime] = useState<ShowTime | undefined>() numPages: 1,
pages: [] as Contract[][],
showTime: null as ShowTime | null,
},
!persistPrefix
? undefined
: { key: `${persistPrefix}-search`, store: historyStore() }
)
const searchParameters = useRef<SearchParameters | undefined>() const searchParams = useRef<SearchParameters | null>(null)
const searchParamsStore = historyStore<SearchParameters>()
const requestId = useRef(0) const requestId = useRef(0)
useLayoutEffect(() => {
if (persistPrefix) {
const params = searchParamsStore.get(`${persistPrefix}-params`)
if (params !== undefined) {
searchParams.current = params
}
}
}, [])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const performQuery = async (freshQuery?: boolean) => { const performQuery = async (freshQuery?: boolean) => {
if (searchParameters.current === undefined) { if (searchParams.current == null) {
return return
} }
const params = searchParameters.current const { query, sort, openClosedFilter, facetFilters } = searchParams.current
const id = ++requestId.current const id = ++requestId.current
const requestedPage = freshQuery ? 0 : pages.length const requestedPage = freshQuery ? 0 : state.pages.length
if (freshQuery || requestedPage < numPages) { if (freshQuery || requestedPage < state.numPages) {
const results = await params.index.search(params.query, { const index = query
facetFilters: params.facetFilters, ? searchIndex
numericFilters: params.numericFilters, : searchClient.initIndex(`${indexPrefix}contracts-${sort}`)
const numericFilters = query
? []
: [
openClosedFilter === 'open' ? `closeTime > ${Date.now()}` : '',
openClosedFilter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
const results = await index.search(query, {
facetFilters,
numericFilters,
page: requestedPage, page: requestedPage,
hitsPerPage: 20, hitsPerPage: 20,
}) })
// if there's a more recent request, forget about this one // if there's a more recent request, forget about this one
if (id === requestId.current) { if (id === requestId.current) {
const newPage = results.hits as any as Contract[] const newPage = results.hits as any as Contract[]
// this spooky looking function is the easiest way to get react to const showTime =
// batch this and not do multiple renders. we can throw it out in react 18. sort === 'close-date' || sort === 'resolve-date' ? sort : null
// see https://github.com/reactwg/react-18/discussions/21 const pages = freshQuery ? [newPage] : [...state.pages, newPage]
unstable_batchedUpdates(() => { setState({ numPages: results.nbPages, pages, showTime })
setShowTime(params.showTime) if (freshQuery && isWholePage) window.scrollTo(0, 0)
setNumPages(results.nbPages)
if (freshQuery) {
setPages([newPage])
if (isWholePage) window.scrollTo(0, 0)
} else {
setPages((pages) => [...pages, newPage])
}
})
} }
} }
} }
const onSearchParametersChanged = useRef( const onSearchParametersChanged = useRef(
debounce((params) => { debounce((params) => {
searchParameters.current = params if (!isEqual(searchParams.current, params)) {
if (persistPrefix) {
searchParamsStore.set(`${persistPrefix}-params`, params)
}
searchParams.current = params
performQuery(true) performQuery(true)
}
}, 100) }, 100)
).current ).current
const contracts = pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = const renderedContracts =
pages.length === 0 ? undefined : contracts.slice(0, maxItems) state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -177,20 +207,24 @@ export function ContractSearch(props: {
defaultFilter={defaultFilter} defaultFilter={defaultFilter}
additionalFilter={additionalFilter} additionalFilter={additionalFilter}
hideOrderSelector={hideOrderSelector} hideOrderSelector={hideOrderSelector}
useQuerySortLocalStorage={useQuerySortLocalStorage} persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined}
useQuerySortUrlParams={useQuerySortUrlParams} useQueryUrlParam={useQueryUrlParam}
user={user} user={user}
onSearchParametersChanged={onSearchParametersChanged} onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls} noControls={noControls}
/> />
{renderContracts ? (
renderContracts(renderedContracts, performQuery)
) : (
<ContractsGrid <ContractsGrid
contracts={renderedContracts} contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery} loadMore={noControls ? undefined : performQuery}
showTime={showTime} showTime={state.showTime ?? undefined}
onContractClick={onContractClick} onContractClick={onContractClick}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions} cardHideOptions={cardHideOptions}
/> />
)}
</Col> </Col>
) )
} }
@ -202,8 +236,8 @@ function ContractSearchControls(props: {
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
hideOrderSelector?: boolean hideOrderSelector?: boolean
onSearchParametersChanged: (params: SearchParameters) => void onSearchParametersChanged: (params: SearchParameters) => void
useQuerySortLocalStorage?: boolean persistPrefix?: string
useQuerySortUrlParams?: boolean useQueryUrlParam?: boolean
user?: User | null user?: User | null
noControls?: boolean noControls?: boolean
}) { }) {
@ -214,25 +248,36 @@ function ContractSearchControls(props: {
additionalFilter, additionalFilter,
hideOrderSelector, hideOrderSelector,
onSearchParametersChanged, onSearchParametersChanged,
useQuerySortLocalStorage, persistPrefix,
useQuerySortUrlParams, useQueryUrlParam,
user, user,
noControls, noControls,
} = props } = props
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null const router = useRouter()
const initialSort = savedSort ?? defaultSort ?? 'score' const [query, setQuery] = usePersistentState(
const querySortOpts = { useUrl: !!useQuerySortUrlParams } '',
const [sort, setSort] = useSort(initialSort, querySortOpts) !useQueryUrlParam
const [query, setQuery] = useQuery('', querySortOpts) ? undefined
const [filter, setFilter] = useState<filter>(defaultFilter ?? 'open') : {
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) key: 'q',
store: urlParamStore(router),
useEffect(() => {
if (useQuerySortLocalStorage) {
setSavedSort(sort)
} }
}, [sort]) )
const [state, setState] = usePersistentState(
{
sort: defaultSort ?? 'score',
filter: defaultFilter ?? 'open',
pillFilter: null as string | null,
},
!persistPrefix
? undefined
: {
key: `${persistPrefix}-params`,
store: storageStore(safeLocalStorage()),
}
)
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter( const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
@ -244,13 +289,26 @@ function ContractSearchControls(props: {
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy( const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0), memberGroups.filter((group) => group.totalContracts > 0),
(group) => group.contractIds.length (group) => group.totalContracts
).reverse() ).reverse()
const pillGroups: { name: string; slug: string }[] = const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [ const additionalFilters = [
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
@ -259,6 +317,11 @@ function ContractSearchControls(props: {
additionalFilter?.groupSlug additionalFilter?.groupSlug
? `groupLinks.slug:${additionalFilter.groupSlug}` ? `groupLinks.slug:${additionalFilter.groupSlug}`
: '', : '',
additionalFilter?.yourBets && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
...(additionalFilter?.followed ? personalFilters : []),
] ]
const facetFilters = query const facetFilters = query
? additionalFilters ? additionalFilters
@ -266,41 +329,31 @@ function ContractSearchControls(props: {
...additionalFilters, ...additionalFilters,
additionalFilter ? '' : 'visibility:public', additionalFilter ? '' : 'visibility:public',
filter === 'open' ? 'isResolved:false' : '', state.filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', state.filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', state.filter === 'resolved' ? 'isResolved:true' : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' state.pillFilter &&
? `groupLinks.slug:${pillFilter}` state.pillFilter !== 'personal' &&
state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}`
: '', : '',
pillFilter === 'personal' ...(state.pillFilter === 'personal' ? personalFilters : []),
? // Show contracts in groups that the user is a member of state.pillFilter === 'your-bets' && user
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
].filter((f) => f) ].filter((f) => f)
const numericFilters = query const openClosedFilter =
? [] state.filter === 'open'
: [ ? 'open'
filter === 'open' ? `closeTime > ${Date.now()}` : '', : state.filter === 'closed'
filter === 'closed' ? `closeTime <= ${Date.now()}` : '', ? 'closed'
].filter((f) => f) : undefined
const selectPill = (pill: string | undefined) => () => { const selectPill = (pill: string | null) => () => {
setPillFilter(pill) setState({ ...state, pillFilter: pill })
track('select search category', { category: pill ?? 'all' }) track('select search category', { category: pill ?? 'all' })
} }
@ -309,34 +362,25 @@ function ContractSearchControls(props: {
} }
const selectFilter = (newFilter: filter) => { const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return if (newFilter === state.filter) return
setFilter(newFilter) setState({ ...state, filter: newFilter })
track('select search filter', { filter: newFilter }) track('select search filter', { filter: newFilter })
} }
const selectSort = (newSort: Sort) => { const selectSort = (newSort: Sort) => {
if (newSort === sort) return if (newSort === state.sort) return
setSort(newSort) setState({ ...state, sort: newSort })
track('select search sort', { sort: newSort }) track('select search sort', { sort: newSort })
} }
const indexName = `${indexPrefix}contracts-${sort}`
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
useEffect(() => { useEffect(() => {
onSearchParametersChanged({ onSearchParametersChanged({
index: query ? searchIndex : index,
query: query, query: query,
numericFilters: numericFilters, sort: state.sort,
openClosedFilter: openClosedFilter,
facetFilters: facetFilters, facetFilters: facetFilters,
showTime:
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
}) })
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
if (noControls) { if (noControls) {
return <></> return <></>
@ -351,14 +395,14 @@ function ContractSearchControls(props: {
type="text" type="text"
value={query} value={query}
onChange={(e) => updateQuery(e.target.value)} onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })} onBlur={trackCallback('search', { query: query })}
placeholder={'Search'} placeholder={'Search'}
className="input input-bordered w-full" className="input input-bordered w-full"
/> />
{!query && ( {!query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={filter} value={state.filter}
onChange={(e) => selectFilter(e.target.value as filter)} onChange={(e) => selectFilter(e.target.value as filter)}
> >
<option value="open">Open</option> <option value="open">Open</option>
@ -370,10 +414,10 @@ function ContractSearchControls(props: {
{!hideOrderSelector && !query && ( {!hideOrderSelector && !query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={sort} value={state.sort}
onChange={(e) => selectSort(e.target.value as Sort)} onChange={(e) => selectSort(e.target.value as Sort)}
> >
{sortOptions.map((option) => ( {SORTS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
@ -386,14 +430,14 @@ function ContractSearchControls(props: {
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto"> <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton <PillButton
key={'all'} key={'all'}
selected={pillFilter === undefined} selected={state.pillFilter === undefined}
onSelect={selectPill(undefined)} onSelect={selectPill(null)}
> >
All All
</PillButton> </PillButton>
<PillButton <PillButton
key={'personal'} key={'personal'}
selected={pillFilter === 'personal'} selected={state.pillFilter === 'personal'}
onSelect={selectPill('personal')} onSelect={selectPill('personal')}
> >
{user ? 'For you' : 'Featured'} {user ? 'For you' : 'Featured'}
@ -402,10 +446,10 @@ function ContractSearchControls(props: {
{user && ( {user && (
<PillButton <PillButton
key={'your-bets'} key={'your-bets'}
selected={pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your trades
</PillButton> </PillButton>
)} )}
@ -413,7 +457,7 @@ function ContractSearchControls(props: {
return ( return (
<PillButton <PillButton
key={slug} key={slug}
selected={pillFilter === slug} selected={state.pillFilter === slug}
onSelect={selectPill(slug)} onSelect={selectPill(slug)}
> >
{name} {name}

View File

@ -1,9 +0,0 @@
import { SparklesIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -35,20 +35,22 @@ import { Tooltip } from '../tooltip'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
showHotVolume?: boolean
showTime?: ShowTime showTime?: ShowTime
className?: string className?: string
questionClass?: string
onClick?: () => void onClick?: () => void
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
trackingPostfix?: string
}) { }) {
const { const {
showHotVolume,
showTime, showTime,
className, className,
questionClass,
onClick, onClick,
hideQuickBet, hideQuickBet,
hideGroupLink, hideGroupLink,
trackingPostfix,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -68,45 +70,20 @@ export function ContractCard(props: {
return ( return (
<Row <Row
className={clsx( className={clsx(
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100', 'group relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
className className
)} )}
> >
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6"> <Col className="relative flex-1 gap-3 py-4 pb-12 pl-6">
{onClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback('click market card', {
slug: contract.slug,
contractId: contract.id,
})}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
<AvatarDetails <AvatarDetails
contract={contract} contract={contract}
className={'hidden md:inline-flex'} className={'hidden md:inline-flex'}
/> />
<p <p
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2" className={clsx(
style={{ /* For iOS safari */ wordBreak: 'break-word' }} 'break-anywhere font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2',
questionClass
)}
> >
{question} {question}
</p> </p>
@ -124,7 +101,7 @@ export function ContractCard(props: {
))} ))}
</Col> </Col>
{showQuickBet ? ( {showQuickBet ? (
<QuickBet contract={contract} user={user} /> <QuickBet contract={contract} user={user} className="z-10" />
) : ( ) : (
<> <>
{outcomeType === 'BINARY' && ( {outcomeType === 'BINARY' && (
@ -165,18 +142,45 @@ export function ContractCard(props: {
showQuickBet ? 'w-[85%]' : 'w-full' showQuickBet ? 'w-[85%]' : 'w-full'
)} )}
> >
<AvatarDetails <AvatarDetails contract={contract} short={true} className="md:hidden" />
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails <MiscDetails
contract={contract} contract={contract}
showHotVolume={showHotVolume}
showTime={showTime} showTime={showTime}
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
/> />
</Row> </Row>
{/* Add click layer */}
{onClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card' + (trackingPostfix ?? ''), {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback(
'click market card' + (trackingPostfix ?? ''),
{
slug: contract.slug,
contractId: contract.id,
}
)}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</Row> </Row>
) )
} }

View File

@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts, parseTags } from 'common/util/parse' import { exhibitExts, parseTags } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { useUser } from 'web/hooks/use-user'
import { updateContract } from 'web/lib/firebase/contracts' import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Content } from '../editor' import { Content } from '../editor'
@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
isCreator: boolean
className?: string className?: string
}) { }) {
const { contract, isCreator, className } = props const { contract, className } = props
const isAdmin = useAdmin() const isAdmin = useAdmin()
const user = useUser()
const isCreator = user?.id === contract.creatorId
return ( return (
<div className={clsx('mt-2 text-gray-700', className)}> <div className={clsx('mt-2 text-gray-700', className)}>
{isCreator || isAdmin ? ( {isCreator || isAdmin ? (
@ -128,6 +130,7 @@ function EditQuestion(props: {
function joinContent(oldContent: ContentType, newContent: string) { function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts }) const editor = new Editor({ content: oldContent, extensions: exhibitExts })
editor.commands.focus('end')
insertContent(editor, newContent) insertContent(editor, newContent)
return editor.getJSON() return editor.getJSON()
} }

View File

@ -2,67 +2,56 @@ import {
ClockIcon, ClockIcon,
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
TrendingUpIcon,
UserGroupIcon, UserGroupIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Editor } from '@tiptap/react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page'
import { Contract, updateContract } from 'web/lib/firebase/contracts' import { Contract, updateContract } from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
import { DateTimeTooltip } from '../datetime-tooltip' import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { useState } from 'react' import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog' import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from 'common/bet'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button' import { UserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link' import { linkClass } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils' import { insertContent } from '../editor/utils'
import clsx from 'clsx'
import { contractMetrics } from 'common/contract-details' import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user' import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip'
import { useWindowSize } from 'web/hooks/use-window-size'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
export function MiscDetails(props: { export function MiscDetails(props: {
contract: Contract contract: Contract
showHotVolume?: boolean
showTime?: ShowTime showTime?: ShowTime
hideGroupLink?: boolean hideGroupLink?: boolean
}) { }) {
const { contract, showHotVolume, showTime, hideGroupLink } = props const { contract, showTime, hideGroupLink } = props
const { const { volume, closeTime, isResolved, createdTime, resolutionTime } =
volume, contract
volume24Hours,
closeTime,
isResolved,
createdTime,
resolutionTime,
groupLinks,
} = contract
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const groupToDisplay = getGroupLinkToDisplay(contract)
return ( return (
<Row className="items-center gap-3 truncate text-sm text-gray-400"> <Row className="items-center gap-3 truncate text-sm text-gray-400">
{showHotVolume ? ( {showTime === 'close-date' ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
<Row className="gap-0.5 whitespace-nowrap"> <Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
@ -77,18 +66,17 @@ export function MiscDetails(props: {
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : ( ) : (
<NewContractBadge /> <NewContractBadge />
)} )}
{!hideGroupLink && groupLinks && groupLinks.length > 0 && ( {!hideGroupLink && groupToDisplay && (
<SiteLink <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
href={groupPath(groupLinks[0].slug)} <a className={clsx(linkClass, 'truncate text-sm text-gray-400')}>
className="truncate text-sm text-gray-400" {groupToDisplay.name}
> </a>
{groupLinks[0].name} </Link>
</SiteLink>
)} )}
</Row> </Row>
) )
@ -100,7 +88,7 @@ export function AvatarDetails(props: {
short?: boolean short?: boolean
}) { }) {
const { contract, short, className } = props const { contract, short, className } = props
const { creatorName, creatorUsername } = contract const { creatorName, creatorUsername, creatorAvatarUrl } = contract
return ( return (
<Row <Row
@ -108,7 +96,7 @@ export function AvatarDetails(props: {
> >
<Avatar <Avatar
username={creatorUsername} username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl} avatarUrl={creatorAvatarUrl}
size={6} size={6}
/> />
<UserLink name={creatorName} username={creatorUsername} short={short} /> <UserLink name={creatorName} username={creatorUsername} short={short} />
@ -116,49 +104,51 @@ export function AvatarDetails(props: {
) )
} }
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
}) {
const { contract, showHotVolume, showTime } = props
return (
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
/>
</Row>
)
}
export function ContractDetails(props: { export function ContractDetails(props: {
contract: Contract contract: Contract
bets: Bet[]
user: User | null | undefined
isCreator?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
const { contract, bets, isCreator, disabled } = props const { contract, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } = const {
contract closeTime,
creatorName,
creatorUsername,
creatorId,
creatorAvatarUrl,
resolutionTime,
} = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract) const { volumeLabel, resolvedDate } = contractMetrics(contract)
const groupToDisplay =
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
const user = useUser() const user = useUser()
const isCreator = user?.id === creatorId
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { width } = useWindowSize()
const groupInfo = ( const isMobile = (width ?? 0) < 600
const groupToDisplay = getGroupLinkToDisplay(contract)
const groupInfo = groupToDisplay ? (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a
className={clsx(
linkClass,
'flex flex-row items-center truncate pr-0 sm:pr-2',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="items-center truncate">{groupToDisplay.name}</span>
</a>
</Link>
) : (
<Button
size={'xs'}
className={'max-w-[200px] pr-2'}
color={'gray-white'}
onClick={() => !groupToDisplay && setOpen(true)}
>
<Row> <Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="truncate"> <span className="truncate">No Group</span>
{groupToDisplay ? groupToDisplay.name : 'No group'}
</span>
</Row> </Row>
</Button>
) )
return ( return (
@ -166,7 +156,7 @@ export function ContractDetails(props: {
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<Avatar <Avatar
username={creatorUsername} username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl} avatarUrl={creatorAvatarUrl}
noLink={disabled} noLink={disabled}
size={6} size={6}
/> />
@ -177,6 +167,7 @@ export function ContractDetails(props: {
className="whitespace-nowrap" className="whitespace-nowrap"
name={creatorName} name={creatorName}
username={creatorUsername} username={creatorUsername}
short={isMobile}
/> />
)} )}
{!disabled && <UserFollowButton userId={creatorId} small />} {!disabled && <UserFollowButton userId={creatorId} small />}
@ -187,39 +178,36 @@ export function ContractDetails(props: {
) : !groupToDisplay && !user ? ( ) : !groupToDisplay && !user ? (
<div /> <div />
) : ( ) : (
<Row>
{groupInfo}
{user && groupToDisplay && (
<Button <Button
size={'xs'} size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'} color={'gray-white'}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{groupInfo} <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
</Button> </Button>
)} )}
</Row> </Row>
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}> <Modal open={open} setOpen={setOpen} size={'md'}>
<Col <Col
className={ className={
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6' 'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
} }
> >
<ContractGroupsList <ContractGroupsList contract={contract} user={user} />
groupLinks={groupLinks ?? []}
contract={contract}
user={user}
/>
</Col> </Col>
</Modal> </Modal>
{(!!closeTime || !!resolvedDate) && ( {(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1"> <Row className="hidden items-center gap-1 md:inline-flex">
{resolvedDate && contract.resolutionTime ? ( {resolvedDate && resolutionTime ? (
<> <>
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
<DateTimeTooltip <DateTimeTooltip text="Market resolved:" time={resolutionTime}>
text="Market resolved:"
time={dayjs(contract.resolutionTime)}
>
{resolvedDate} {resolvedDate}
</DateTimeTooltip> </DateTimeTooltip>
</> </>
@ -239,17 +227,84 @@ export function ContractDetails(props: {
)} )}
{user && ( {user && (
<> <>
<Row className="items-center gap-1"> <Row className="hidden items-center gap-1 md:inline-flex">
<DatabaseIcon className="h-5 w-5" /> <DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div> <div className="whitespace-nowrap">{volumeLabel}</div>
</Row> </Row>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />} {!disabled && (
<ContractInfoDialog
contract={contract}
className={'hidden md:inline-flex'}
/>
)}
</> </>
)} )}
</Row> </Row>
) )
} }
export function ExtraMobileContractDetails(props: {
contract: Contract
forceShowVolume?: boolean
}) {
const { contract, forceShowVolume } = props
const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } =
contract
const user = useUser()
const uniqueBettors = uniqueBettorCount ?? 0
const { resolvedDate } = contractMetrics(contract)
const volumeTranslation =
volume > 800 || uniqueBettors >= 20
? 'High'
: volume > 300 || uniqueBettors >= 10
? 'Medium'
: 'Low'
return (
<Row
className={clsx(
'items-center justify-around md:hidden',
user ? 'w-full' : ''
)}
>
{resolvedDate && resolutionTime ? (
<Col className={'items-center text-sm'}>
<Row className={'text-gray-500'}>
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
{resolvedDate}
</DateTimeTooltip>
</Row>
<Row className={'text-gray-400'}>Ended</Row>
</Col>
) : (
!resolvedDate &&
closeTime && (
<Col className={'items-center text-sm text-gray-500'}>
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={creatorId === user?.id}
/>
<Row className={'text-gray-400'}>Ends</Row>
</Col>
)
)}
{(user || forceShowVolume) && (
<Col className={'items-center text-sm text-gray-500'}>
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique traders`}
>
{volumeTranslation}
</Tooltip>
<Row className={'text-gray-400'}>Activity</Row>
</Col>
)}
</Row>
)
}
function EditableCloseDate(props: { function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
@ -262,14 +317,22 @@ function EditableCloseDate(props: {
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState( const [closeDate, setCloseDate] = useState(
closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm') closeTime && dayJsCloseTime.format('YYYY-MM-DD')
) )
const [closeHoursMinutes, setCloseHoursMinutes] = useState(
closeTime && dayJsCloseTime.format('HH:mm')
)
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => { const onSave = () => {
const newCloseTime = dayjs(closeDate).valueOf() if (!newCloseTime) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false) if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) { else if (newCloseTime > Date.now()) {
const content = contract.description const content = contract.description
@ -294,42 +357,46 @@ function EditableCloseDate(props: {
return ( return (
<> <>
{isEditingCloseTime ? ( {isEditingCloseTime ? (
<div className="form-control mr-1 items-start"> <Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
<input <input
type="datetime-local" type="date"
className="input input-bordered" className="input input-bordered shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')} onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()} min={Date.now()}
value={closeDate} value={closeDate}
/> />
</div> <input
type="time"
className="input input-bordered shrink-0"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
<Button size={'xs'} color={'blue'} onClick={onSave}>
Done
</Button>
</Row>
) : ( ) : (
<DateTimeTooltip <DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={dayJsCloseTime} time={closeTime}
> >
{isSameYear <span
? dayJsCloseTime.format('MMM D') className={isCreator ? 'cursor-pointer' : ''}
: dayJsCloseTime.format('MMM D, YYYY')} onClick={() => isCreator && setIsEditingCloseTime(true)}
{isSameDay && <> ({fromNow(closeTime)})</>} >
{isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span>
) : isSameYear ? (
dayJsCloseTime.format('MMM D')
) : (
dayJsCloseTime.format('MMM D, YYYY')
)}
</span>
</DateTimeTooltip> </DateTimeTooltip>
)} )}
{isCreator &&
(isEditingCloseTime ? (
<button className="btn btn-xs" onClick={onSave}>
Done
</button>
) : (
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
</Button>
))}
</> </>
) )
} }

View File

@ -1,9 +1,7 @@
import { DotsHorizontalIcon } from '@heroicons/react/outline' import { DotsHorizontalIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { uniqBy } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -17,12 +15,18 @@ import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants' import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore' import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { export function ContractInfoDialog(props: {
const { contract, bets } = props contract: Contract
className?: string
}) {
const { contract, className } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState( const [featured, setFeatured] = useState(
@ -31,16 +35,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const isDev = useDev() const isDev = useDev()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract contract
const tradersCount = uniqBy( const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
bets.filter((bet) => !bet.isAnte),
'userId'
).length
const typeDisplay = const typeDisplay =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? 'YES / NO' ? 'YES / NO'
@ -50,10 +50,25 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'Multiple choice' ? 'Multiple choice'
: 'Numeric' : 'Numeric'
const onFeaturedToggle = async (enabled: boolean) => {
if (
enabled &&
(contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank)
) {
await updateContract(id, { featuredOnHomeRank: 1 })
setFeatured(true)
} else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await updateContract(id, { featuredOnHomeRank: deleteField() })
setFeatured(false)
}
}
return ( return (
<> <>
<button <button
className={contractDetailsButtonClassName} className={clsx(contractDetailsButtonClassName, className)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<DotsHorizontalIcon <DotsHorizontalIcon
@ -121,7 +136,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<tr> <tr>
<td>Traders</td> <td>Traders</td>
<td>{tradersCount}</td> <td>{bettorsCount}</td>
</tr> </tr>
<tr> <tr>
@ -134,7 +149,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
{/* Show a path to Firebase if user is an admin, or we're on localhost */} {/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && ( {(isAdmin || isDev) && (
<tr> <tr>
<td>[DEV] Firestore</td> <td>[ADMIN] Firestore</td>
<td> <td>
<SiteLink href={firestoreConsolePath(id)}> <SiteLink href={firestoreConsolePath(id)}>
Console link Console link
@ -144,49 +159,37 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
)} )}
{isAdmin && ( {isAdmin && (
<tr> <tr>
<td>Set featured</td> <td>[ADMIN] Featured</td>
<td> <td>
<select <ShortToggle
className="select select-bordered" enabled={featured}
value={featured ? 'true' : 'false'} setEnabled={setFeatured}
onChange={(e) => { onChange={onFeaturedToggle}
const newVal = e.target.value === 'true' />
if ( </td>
newVal && </tr>
(contract.featuredOnHomeRank === 0 || )}
!contract?.featuredOnHomeRank) {isAdmin && (
) <tr>
<td>[ADMIN] Unlisted</td>
<td>
<ShortToggle
enabled={contract.visibility === 'unlisted'}
setEnabled={(b) =>
updateContract(id, { updateContract(id, {
featuredOnHomeRank: 1, visibility: b ? 'unlisted' : 'public',
}) })
.then(() => { }
setFeatured(true) />
})
.catch(console.error)
else if (
!newVal &&
(contract?.featuredOnHomeRank ?? 0) > 0
)
updateContract(id, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
featuredOnHomeRank: deleteField(),
})
.then(() => {
setFeatured(false)
})
.catch(console.error)
}}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
<Row className="flex-wrap">
<DuplicateContractButton contract={contract} />
</Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} /> <LiquidityPanel contract={contract} />
)} )}

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top traders"
users={users || []} users={users || []}
columns={[ columns={[
{ {
@ -107,13 +107,8 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]} tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]} betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/> />
</div> </div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} /> <Spacer h={16} />
</> </>
)} )}
@ -121,16 +116,11 @@ export function ContractTopTrades(props: {
{/* If they're the same, only show the comment; otherwise show both */} {/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<> <>
<Title text="💸 Smartest money" className="!mt-0" /> <Title text="💸 Best bet" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet <FeedBet contract={contract} bet={betsById[topBetId]} />
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div> </div>
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 ml-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div> </div>
</> </>

View File

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -16,125 +15,154 @@ import {
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetButton from '../bet-button' import BetButton from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract' import {
import { ContractDescription } from './contract-description' Contract,
import { ContractDetails } from './contract-details' BinaryContract,
CPMMContract,
CPMMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
PseudoNumericContract,
} from 'common/contract'
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph' import { NumericGraph } from './numeric-graph'
import { ShareRow } from './share-row'
const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} />
)
const BetWidget = (props: { contract: CPMMContract }) => {
const user = useUser()
return (
<Col>
<BetButton contract={props.contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)
}
const NumericOverview = (props: { contract: NumericContract }) => {
const { contract } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<NumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
</Row>
<NumericResolutionOrExpectation
className="items-center justify-between gap-4 xl:hidden"
contract={contract}
/>
</Col>
<NumericGraph contract={contract} />
</Col>
)
}
const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance
className="hidden items-end xl:flex"
contract={contract}
large
/>
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} />
)}
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
</Col>
)
}
const ChoiceOverview = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
}) => {
const { contract, bets } = props
const { question, resolution } = contract
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<OverviewQuestion text={question} />
{resolution && (
<FreeResponseResolutionOrChance contract={contract} truncate="none" />
)}
</Col>
<Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
<ExtraMobileContractDetails
contract={contract}
forceShowVolume={true}
/>
</Col>
</Col>
)
}
const PseudoNumericOverview = (props: {
contract: PseudoNumericContract
bets: Bet[]
}) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
</Col>
)
}
export const ContractOverview = (props: { export const ContractOverview = (props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
className?: string
}) => { }) => {
const { contract, bets, className } = props const { contract, bets } = props
const { question, creatorId, outcomeType, resolution } = contract switch (contract.outcomeType) {
case 'BINARY':
const user = useUser() return <BinaryOverview contract={contract} bets={bets} />
const isCreator = user?.id === creatorId case 'NUMERIC':
return <NumericOverview contract={contract} />
const isBinary = outcomeType === 'BINARY' case 'PSEUDO_NUMERIC':
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE':
return ( case 'MULTIPLE_CHOICE':
<Col className={clsx('mb-6', className)}> return <ChoiceOverview contract={contract} bets={bets} />
<Col className="gap-3 px-2 sm:gap-4"> }
<Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} />
</div>
<Row className={'hidden gap-3 xl:flex'}>
{isBinary && (
<BinaryResolutionOrChance
className="items-end"
contract={contract}
large
/>
)}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
</Row>
</Row>
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract as CPMMBinaryContract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
resolution && (
<FreeResponseResolutionOrChance
contract={contract}
truncate="none"
/>
)
)}
{outcomeType === 'NUMERIC' && (
<Row className="items-center justify-between gap-4 xl:hidden">
<NumericResolutionOrExpectation contract={contract} />
</Row>
)}
<ContractDetails
contract={contract}
bets={bets}
isCreator={isCreator}
user={user}
/>
</Col>
<div className={'my-1 md:my-2'}></div>
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<AnswersGraph contract={contract} bets={bets} />
)}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
<ShareRow user={user} contract={contract} />
<ContractDescription
className="px-2"
contract={contract}
isCreator={isCreator}
/>
</Col>
)
} }

View File

@ -16,6 +16,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
}) { }) {
const { contract, height } = props const { contract, height } = props
const { resolutionTime, closeTime, outcomeType } = contract const { resolutionTime, closeTime, outcomeType } = contract
const now = Date.now()
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
@ -23,10 +24,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const startProb = getInitialProbability(contract) const startProb = getInitialProbability(contract)
const times = [ const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
contract.createdTime,
...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time))
const f: (p: number) => number = isBinary const f: (p: number) => number = isBinary
? (p) => p ? (p) => p
@ -36,17 +34,17 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs( const latestTime = dayjs(
resolutionTime && isClosed resolutionTime && isClosed
? Math.min(resolutionTime, closeTime) ? Math.min(resolutionTime, closeTime)
: isClosed : isClosed
? closeTime ? closeTime
: resolutionTime ?? Date.now() : resolutionTime ?? now
) )
// Add a fake datapoint so the line continues to the right // Add a fake datapoint so the line continues to the right
times.push(latestTime.toDate()) times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1]) probs.push(probs[probs.length - 1])
const quartiles = [0, 25, 50, 75, 100] const quartiles = [0, 25, 50, 75, 100]
@ -58,15 +56,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize() const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5 const numXTickValues = !width || width < 800 ? 2 : 5
const hoursAgo = latestTime.subtract(1, 'hours') const startDate = dayjs(times[0])
const startDate = dayjs(times[0]).isBefore(hoursAgo) const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? times[0] ? latestTime.add(1, 'hours')
: hoursAgo.toDate() : latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
// Minimum number of points for the graph to have. For smooth tooltip movement // Minimum number of points for the graph to have. For smooth tooltip movement
// On first load, width is undefined, skip adding extra points to let page load faster // If we aren't actually loading any data yet, skip adding extra points to let page load faster
// This fn runs again once DOM is finished loading // This fn runs again once DOM is finished loading
const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
@ -74,20 +73,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const s = isBinary ? 100 : 1 const s = isBinary ? 100 : 1
for (let i = 0; i < times.length - 1; i++) { for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: s * probs[i] } const p = probs[i]
const numPoints: number = Math.floor( const d0 = times[i]
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep const d1 = times[i + 1]
) const msDiff = d1 - d0
const numPoints = Math.floor(msDiff / timeStep)
points.push({ x: new Date(times[i]), y: s * p })
if (numPoints > 1) { if (numPoints > 1) {
const thisTimeStep: number = const thisTimeStep: number = msDiff / numPoints
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / numPoints
for (let n = 1; n < numPoints; n++) { for (let n = 1; n < numPoints; n++) {
points[points.length] = { points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
x: dayjs(times[i])
.add(thisTimeStep * n, 'ms')
.toDate(),
y: s * probs[i],
}
} }
} }
} }
@ -96,8 +91,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
] ]
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
const formatter = isBinary const formatter = isBinary
? formatPercent ? formatPercent
@ -132,15 +127,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
}} }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: startDate, min: startDate.toDate(),
max: latestTime.toDate(), max: endDate.toDate(),
}} }}
xFormat={(d) => xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
} }
axisBottom={{ axisBottom={{
tickValues: numXTickValues, tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}} }}
colors={{ datum: 'color' }} colors={{ datum: 'color' }}
curve="stepAfter" curve="stepAfter"
@ -176,19 +172,20 @@ function formatPercent(y: DatumValue) {
} }
function formatTime( function formatTime(
now: number,
time: number, time: number,
includeYear: boolean, includeYear: boolean,
includeHour: boolean, includeHour: boolean,
includeMinute: boolean includeMinute: boolean
) { ) {
const d = dayjs(time) const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' return 'Now'
let format: string let format: string
if (d.isSame(Date.now(), 'day')) { if (d.isSame(now, 'day')) {
format = '[Today]' format = '[Today]'
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) { } else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]' format = '[Yesterday]'
} else { } else {
format = 'MMM D' format = 'MMM D'

View File

@ -1,15 +1,24 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractActivity } from '../feed/contract-activity' import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -18,67 +27,68 @@ export function ContractTabs(props: {
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, bets, tips } = props const { contract, user, tips } = props
const { outcomeType } = contract const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id) const bets = useBets(contract.id) ?? props.bets
const lps = useLiquidity(contract.id) ?? []
const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0)
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
// Load comments here, so the badge count will be correct // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments
const betActivity = ( const betActivity = (
<ContractActivity <ContractBetsActivity
contract={contract} contract={contract}
bets={bets} bets={visibleBets}
liquidityProvisions={liquidityProvisions} lps={visibleLps}
comments={comments}
tips={tips}
user={user}
mode="bets"
betRowClassName="!mt-0 xl:hidden"
/> />
) )
const commentActivity = ( const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets
const generalComments = comments.filter(
(comment) =>
comment.answerOutcome === undefined &&
(outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true)
)
const commentActivity =
outcomeType === 'FREE_RESPONSE' ? (
<> <>
<ContractActivity <FreeResponseContractCommentsActivity
contract={contract} contract={contract}
bets={bets} bets={visibleBets}
liquidityProvisions={liquidityProvisions}
comments={comments} comments={comments}
tips={tips} tips={tips}
user={user} user={user}
mode={
contract.outcomeType === 'FREE_RESPONSE'
? 'free-response-comment-answer-groups'
: 'comments'
}
betRowClassName="!mt-0 xl:hidden"
/> />
{outcomeType === 'FREE_RESPONSE' && (
<Col className={'mt-8 flex w-full '}> <Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div> <div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} /> <div className={'mb-4 w-full border-b border-gray-200'} />
<ContractActivity <ContractCommentsActivity
contract={contract} contract={contract}
bets={bets} bets={generalBets}
liquidityProvisions={liquidityProvisions} comments={generalComments}
tips={tips}
user={user}
/>
</Col>
</>
) : (
<ContractCommentsActivity
contract={contract}
bets={visibleBets}
comments={comments} comments={comments}
tips={tips} tips={tips}
user={user} user={user}
mode={'comments'}
betRowClassName="!mt-0 xl:hidden"
/> />
</Col>
)}
</>
) )
const yourTrades = ( const yourTrades = (
@ -96,6 +106,7 @@ export function ContractTabs(props: {
) )
return ( return (
<>
<Tabs <Tabs
currentPageForAnalytics={'contract'} currentPageForAnalytics={'contract'}
tabs={[ tabs={[
@ -104,11 +115,30 @@ export function ContractTabs(props: {
content: commentActivity, content: commentActivity,
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` }, {
title: 'Trades',
content: betActivity,
badge: `${visibleBets.length}`,
},
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your trades', content: yourTrades }]),
]} ]}
/> />
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</>
) )
} }

View File

@ -26,6 +26,8 @@ export function ContractsGrid(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
trackingPostfix?: string
breakpointColumns?: { [key: string]: number }
}) { }) {
const { const {
contracts, contracts,
@ -34,6 +36,7 @@ export function ContractsGrid(props: {
onContractClick, onContractClick,
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
trackingPostfix,
} = props } = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {} const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {} const { contractIds, highlightClassName } = highlightOptions || {}
@ -65,7 +68,7 @@ export function ContractsGrid(props: {
<Col className="gap-8"> <Col className="gap-8">
<Masonry <Masonry
// Show only 1 column on tailwind's md breakpoint (768px) // Show only 1 column on tailwind's md breakpoint (768px)
breakpointCols={{ default: 2, 768: 1 }} breakpointCols={props.breakpointColumns ?? { default: 2, 768: 1 }}
className="-ml-4 flex w-auto" className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding" columnClassName="pl-4 bg-clip-padding"
> >
@ -79,6 +82,7 @@ export function ContractsGrid(props: {
} }
hideQuickBet={hideQuickBet} hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
className={clsx( className={clsx(
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
contractIds?.includes(contract.id) && highlightClassName contractIds?.includes(contract.id) && highlightClassName
@ -110,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{ additionalFilter={{
creatorId: creator.id, creatorId: creator.id,
}} }}
persistPrefix={`user-${creator.id}`}
/> />
) )
} }

View File

@ -0,0 +1,86 @@
import clsx from 'clsx'
import { ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts'
import React, { useState } from 'react'
import { Button } from 'web/components/button'
import { useUser } from 'web/hooks/use-user'
import { ShareModal } from './share-modal'
import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props
const { outcomeType, resolution } = contract
const user = useUser()
const [isShareOpen, setShareOpen] = useState(false)
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false)
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
return (
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
<Button
size="lg"
color="gray-white"
className={'flex'}
onClick={() => {
setShareOpen(true)
}}
>
<Col className={'items-center sm:flex-row'}>
<ShareIcon
className={clsx('h-[24px] w-5 sm:mr-2')}
aria-hidden="true"
/>
<span>Share</span>
</Col>
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
contract={contract}
user={user}
/>
</Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<Col className="items-center sm:flex-row">
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}
user={user}
contract={contract}
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Col className={'justify-center md:hidden'}>
<ContractInfoDialog contract={contract} />
</Col>
</Row>
)
}

View File

@ -0,0 +1,9 @@
import { BadgeCheckIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-green-800">
<BadgeCheckIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -0,0 +1,61 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
export function LikeMarketButton(props: {
contract: Contract
user: User | null | undefined
}) {
const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id
)
const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount))
}, [tips])
const likes = useUserLikes(user?.id)
const userLikedContractIds = likes
?.filter((l) => l.type === 'contract')
.map((l) => l.id)
const onLike = async () => {
if (!user) return firebaseLogin()
await likeContract(user, contract)
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
}
return (
<Button
size={'lg'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<Col className={'items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-[24px] w-5 sm:mr-2',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
/>
Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''}
</Col>
</Button>
)
}

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