Merge branch 'main' into monthly-leaderboard
This commit is contained in:
commit
5f8cd66420
43
.github/workflows/lint.yml
vendored
Normal file
43
.github/workflows/lint.yml
vendored
Normal 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 }}
|
|
@ -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
158
common/calculate-metrics.ts
Normal 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 = calculateTotalProfit(startingPortfolio)
|
||||||
|
|
||||||
|
return currentProfit - startingProfit
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||||
|
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateNewProfit = (
|
||||||
|
portfolioHistory: PortfolioMetrics[],
|
||||||
|
newPortfolio: PortfolioMetrics
|
||||||
|
) => {
|
||||||
|
const allTimeProfit = calculateTotalProfit(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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -23,10 +23,16 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||||
type OnContract = {
|
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 = {
|
type OnGroup = {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,6 +57,10 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
popularityScore?: number
|
popularityScore?: number
|
||||||
|
followerCount?: number
|
||||||
|
featuredOnHomeRank?: number
|
||||||
|
likedByUserIds?: string[]
|
||||||
|
likedByUserCount?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
@ -83,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 = {
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
|
||||||
|
|
||||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
|
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100
|
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
||||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
|
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
|
||||||
|
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||||
|
|
|
@ -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) + '$'
|
||||||
|
@ -44,3 +49,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
|
||||||
)
|
)
|
||||||
// Any localhost server on any port
|
// Any localhost server on any port
|
||||||
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
|
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
|
||||||
|
|
||||||
|
export function firestoreConsolePath(contractId: string) {
|
||||||
|
return `https://console.firebase.google.com/project/${PROJECT_ID}/firestore/data/~2Fcontracts~2F${contractId}`
|
||||||
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ export type Economy = {
|
||||||
BETTING_STREAK_BONUS_AMOUNT?: number
|
BETTING_STREAK_BONUS_AMOUNT?: number
|
||||||
BETTING_STREAK_BONUS_MAX?: number
|
BETTING_STREAK_BONUS_MAX?: number
|
||||||
BETTING_STREAK_RESET_HOUR?: number
|
BETTING_STREAK_RESET_HOUR?: number
|
||||||
|
FREE_MARKETS_PER_USER_MAX?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig = {
|
type FirebaseConfig = {
|
||||||
|
@ -70,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
'taowell@gmail.com', // Stephen
|
'taowell@gmail.com', // Stephen
|
||||||
'abc.sinclair@gmail.com', // Sinclair
|
'abc.sinclair@gmail.com', // Sinclair
|
||||||
'manticmarkets@gmail.com', // Manifold
|
'manticmarkets@gmail.com', // Manifold
|
||||||
|
'iansphilips@gmail.com', // Ian
|
||||||
|
'd4vidchee@gmail.com', // D4vid
|
||||||
],
|
],
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export const PLATFORM_FEE = 0
|
export const PLATFORM_FEE = 0
|
||||||
export const CREATOR_FEE = 0.1
|
export const CREATOR_FEE = 0
|
||||||
export const LIQUIDITY_FEE = 0
|
export const LIQUIDITY_FEE = 0
|
||||||
|
|
||||||
export const DPM_PLATFORM_FEE = 0.01
|
export const DPM_PLATFORM_FEE = 0.0
|
||||||
export const DPM_CREATOR_FEE = 0.04
|
export const DPM_CREATOR_FEE = 0.0
|
||||||
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
||||||
|
|
||||||
export type Fees = {
|
export type Fees = {
|
||||||
|
|
|
@ -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
8
common/like.ts
Normal 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
|
|
@ -10,11 +10,11 @@ import {
|
||||||
import { PortfolioMetrics, User } from './user'
|
import { PortfolioMetrics, User } from './user'
|
||||||
import { filterDefined } from './util/array'
|
import { filterDefined } from './util/array'
|
||||||
|
|
||||||
const LOAN_WEEKLY_RATE = 0.05
|
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
|
||||||
return netValue * LOAN_WEEKLY_RATE
|
return netValue * LOAN_DAILY_RATE
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLoanUpdates = (
|
export const getLoanUpdates = (
|
||||||
|
@ -101,7 +101,7 @@ const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
|
||||||
const oldestBet = minBy(bets, (bet) => bet.createdTime)
|
const oldestBet = minBy(bets, (bet) => bet.createdTime)
|
||||||
|
|
||||||
const newLoan = calculateNewLoan(invested, loanAmount)
|
const newLoan = calculateNewLoan(invested, loanAmount)
|
||||||
if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined
|
if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined
|
||||||
|
|
||||||
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
|
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
|
||||||
|
|
||||||
|
@ -118,14 +118,14 @@ 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
|
||||||
const newLoan = calculateNewLoan(bet.amount, loanAmount)
|
const newLoan = calculateNewLoan(bet.amount, loanAmount)
|
||||||
const loanTotal = loanAmount + newLoan
|
const loanTotal = loanAmount + newLoan
|
||||||
|
|
||||||
if (isNaN(newLoan) || newLoan <= 0) return undefined
|
if (!isFinite(newLoan) || newLoan <= 0) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: bet.userId,
|
userId: bet.userId,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -70,3 +73,6 @@ export type notification_reason_types =
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
|
| 'you_follow_contract'
|
||||||
|
| 'liked_your_contract'
|
||||||
|
| 'liked_and_tipped_your_contract'
|
||||||
|
|
|
@ -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
12
common/post.ts
Normal 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
|
|
@ -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
|
||||||
|
@ -42,6 +43,9 @@ export type User = {
|
||||||
shouldShowWelcome?: boolean
|
shouldShowWelcome?: boolean
|
||||||
lastBetTime?: number
|
lastBetTime?: number
|
||||||
currentBettingStreak?: number
|
currentBettingStreak?: number
|
||||||
|
hasSeenContractFollowModal?: boolean
|
||||||
|
freeMarketsCreated?: number
|
||||||
|
isBannedFromPosting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
|
@ -54,6 +58,7 @@ export type PrivateUser = {
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
unsubscribedFromAnswerEmails?: boolean
|
||||||
unsubscribedFromGenericEmails?: boolean
|
unsubscribedFromGenericEmails?: boolean
|
||||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||||
|
weeklyTrendingEmailSent?: boolean
|
||||||
manaBonusEmailSent?: boolean
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
||||||
npx concurrently \
|
npx concurrently \
|
||||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||||
-c green,white,magenta,cyan \
|
-c green,white,magenta,cyan \
|
||||||
"yarn --cwd=functions firestore" \
|
"yarn --cwd=functions localDbScript" \
|
||||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||||
|
|
|
@ -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.
|
||||||
|
@ -97,7 +107,6 @@ Requires no authorization.
|
||||||
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||||
"closeTime":1653893940000,
|
"closeTime":1653893940000,
|
||||||
"question":"Will I write a new blog post today?",
|
"question":"Will I write a new blog post today?",
|
||||||
"description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm",
|
|
||||||
"tags":[
|
"tags":[
|
||||||
"personal",
|
"personal",
|
||||||
"commitments"
|
"commitments"
|
||||||
|
@ -135,8 +144,6 @@ Requires no authorization.
|
||||||
// Market attributes. All times are in milliseconds since epoch
|
// Market attributes. All times are in milliseconds since epoch
|
||||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||||
question: string
|
question: string
|
||||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
|
||||||
textDescription: string // string description without formatting, images, or embeds
|
|
||||||
|
|
||||||
// A list of tags on each market. Any user can add tags to any market.
|
// A list of tags on each market. Any user can add tags to any market.
|
||||||
// This list also includes the predefined categories shown as filters on the home page.
|
// This list also includes the predefined categories shown as filters on the home page.
|
||||||
|
@ -398,6 +405,8 @@ Requires no authorization.
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
answers?: Answer[]
|
answers?: Answer[]
|
||||||
|
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||||
|
textDescription: string // string description without formatting, images, or embeds
|
||||||
}
|
}
|
||||||
|
|
||||||
type Bet = {
|
type Bet = {
|
||||||
|
|
|
@ -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) 
|
||||||
|
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -10,7 +10,9 @@ service cloud.firestore {
|
||||||
'akrolsmir@gmail.com',
|
'akrolsmir@gmail.com',
|
||||||
'jahooma@gmail.com',
|
'jahooma@gmail.com',
|
||||||
'taowell@gmail.com',
|
'taowell@gmail.com',
|
||||||
'manticmarkets@gmail.com'
|
'abc.sinclair@gmail.com',
|
||||||
|
'manticmarkets@gmail.com',
|
||||||
|
'iansphilips@gmail.com'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,7 +24,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
|
||||||
// User referral rules
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -43,6 +45,11 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /contracts/{contractId}/follows/{userId} {
|
||||||
|
allow read;
|
||||||
|
allow create, delete: if userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
match /contracts/{contractId}/challenges/{challengeId}{
|
match /contracts/{contractId}/challenges/{challengeId}{
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||||
|
@ -55,6 +62,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;
|
||||||
}
|
}
|
||||||
|
@ -148,25 +160,49 @@ service cloud.firestore {
|
||||||
.hasOnly(['isSeen', 'viewTime']);
|
.hasOnly(['isSeen', 'viewTime']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groups/{groupId} {
|
match /{somePath=**}/groupMembers/{memberId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /{somePath=**}/groupContracts/{contractId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
"deploy": "firebase deploy --only functions",
|
"deploy": "firebase deploy --only functions",
|
||||||
"logs": "firebase functions:log",
|
"logs": "firebase functions:log",
|
||||||
"dev": "nodemon src/serve.ts",
|
"dev": "nodemon src/serve.ts",
|
||||||
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||||
|
@ -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",
|
||||||
|
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -15,15 +15,17 @@ import {
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
|
|
||||||
import { chargeUser, getContract } from './utils'
|
import { chargeUser, getContract, isProd } from './utils'
|
||||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||||
|
|
||||||
import { FIXED_ANTE } from 'common/economy'
|
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
getCpmmInitialLiquidity,
|
getCpmmInitialLiquidity,
|
||||||
getFreeAnswerAnte,
|
getFreeAnswerAnte,
|
||||||
getMultipleChoiceAntes,
|
getMultipleChoiceAntes,
|
||||||
getNumericAnte,
|
getNumericAnte,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
|
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { uniq, zip } from 'lodash'
|
import { uniq, zip } from 'lodash'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const user = userDoc.data() as User
|
const user = userDoc.data() as User
|
||||||
|
|
||||||
const ante = FIXED_ANTE
|
const ante = FIXED_ANTE
|
||||||
|
const deservesFreeMarket =
|
||||||
|
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||||
// TODO: this is broken because it's not in a transaction
|
// TODO: this is broken because it's not in a transaction
|
||||||
if (ante > user.balance)
|
if (ante > user.balance && !deservesFreeMarket)
|
||||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||||
|
|
||||||
let group: Group | null = null
|
let group: Group | null = null
|
||||||
|
@ -151,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
|
||||||
) {
|
) {
|
||||||
|
@ -207,22 +217,40 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
visibility
|
visibility
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ante) await chargeUser(user.id, ante, true)
|
const providerId = deservesFreeMarket
|
||||||
|
? isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: user.id
|
||||||
|
|
||||||
|
if (ante) await chargeUser(providerId, ante, true)
|
||||||
|
if (deservesFreeMarket)
|
||||||
|
await firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.update({ freeMarketsCreated: FieldValue.increment(1) })
|
||||||
|
|
||||||
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerId = user.id
|
|
||||||
|
|
||||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
const liquidityDoc = firestore
|
const liquidityDoc = firestore
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getValues } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -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 = {
|
||||||
|
@ -33,19 +34,12 @@ export const createNotification = async (
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
contract?: Contract
|
contract?: Contract
|
||||||
relatedSourceType?: notification_source_types
|
|
||||||
recipients?: string[]
|
recipients?: string[]
|
||||||
slug?: string
|
slug?: string
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const {
|
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||||
contract: sourceContract,
|
|
||||||
relatedSourceType,
|
|
||||||
recipients,
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
} = miscData ?? {}
|
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
|
@ -90,24 +84,6 @@ export const createNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyLiquidityProviders = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
contract: Contract
|
|
||||||
) => {
|
|
||||||
const liquidityProviders = await firestore
|
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
|
||||||
.get()
|
|
||||||
const liquidityProvidersIds = uniq(
|
|
||||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
|
||||||
)
|
|
||||||
liquidityProvidersIds.forEach((userId) => {
|
|
||||||
if (!shouldGetNotification(userId, userToReasonTexts)) return
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'on_contract_with_users_shares_in',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyUsersFollowers = async (
|
const notifyUsersFollowers = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: user_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
|
@ -129,23 +105,6 @@ export const createNotification = async (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyRepliedUser = (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
relatedUserId: string,
|
|
||||||
relatedSourceType: notification_source_types
|
|
||||||
) => {
|
|
||||||
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
|
|
||||||
if (relatedSourceType === 'comment') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_comment',
|
|
||||||
}
|
|
||||||
} else if (relatedSourceType === 'answer') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_answer',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyFollowedUser = (
|
const notifyFollowedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
followedUserId: string
|
followedUserId: string
|
||||||
|
@ -182,9 +141,150 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyOtherAnswerersOnContract = async (
|
const notifyUserAddedToGroup = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
sourceContract: Contract
|
relatedUserId: string
|
||||||
|
) => {
|
||||||
|
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
||||||
|
userToReasonTexts[relatedUserId] = {
|
||||||
|
reason: 'added_you_to_group',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
|
||||||
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
|
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||||
|
} else if (
|
||||||
|
sourceType === 'group' &&
|
||||||
|
sourceUpdateType === 'created' &&
|
||||||
|
recipients
|
||||||
|
) {
|
||||||
|
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||||
|
} else if (
|
||||||
|
sourceType === 'contract' &&
|
||||||
|
sourceUpdateType === 'created' &&
|
||||||
|
sourceContract
|
||||||
|
) {
|
||||||
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
|
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
|
} else if (
|
||||||
|
sourceType === 'contract' &&
|
||||||
|
sourceUpdateType === 'closed' &&
|
||||||
|
sourceContract
|
||||||
|
) {
|
||||||
|
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
} else if (
|
||||||
|
sourceType === 'liquidity' &&
|
||||||
|
sourceUpdateType === 'created' &&
|
||||||
|
sourceContract
|
||||||
|
) {
|
||||||
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createUsersNotifications(userToReasonTexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
|
sourceId: string,
|
||||||
|
sourceType: notification_source_types,
|
||||||
|
sourceUpdateType: notification_source_update_types,
|
||||||
|
sourceUser: User,
|
||||||
|
idempotencyKey: string,
|
||||||
|
sourceText: string,
|
||||||
|
sourceContract: Contract,
|
||||||
|
miscData?: {
|
||||||
|
relatedSourceType?: notification_source_types
|
||||||
|
repliedUserId?: string
|
||||||
|
taggedUserIds?: string[]
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
|
||||||
|
|
||||||
|
const createUsersNotifications = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
await Promise.all(
|
||||||
|
Object.keys(userToReasonTexts).map(async (userId) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${userId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId,
|
||||||
|
reason: userToReasonTexts[userId].reason,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId,
|
||||||
|
sourceType,
|
||||||
|
sourceUpdateType,
|
||||||
|
sourceContractId: sourceContract.id,
|
||||||
|
sourceUserName: sourceUser.name,
|
||||||
|
sourceUserUsername: sourceUser.username,
|
||||||
|
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||||
|
sourceText,
|
||||||
|
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
||||||
|
sourceContractTitle: sourceContract.question,
|
||||||
|
sourceContractSlug: sourceContract.slug,
|
||||||
|
sourceSlug: sourceContract.slug,
|
||||||
|
sourceTitle: sourceContract.question,
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get contract follower documents and check here if they're a follower
|
||||||
|
const contractFollowersSnap = await firestore
|
||||||
|
.collection(`contracts/${sourceContract.id}/follows`)
|
||||||
|
.get()
|
||||||
|
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||||
|
(doc) => doc.data().id
|
||||||
|
)
|
||||||
|
log('contractFollowerIds', contractFollowersIds)
|
||||||
|
|
||||||
|
const stillFollowingContract = (userId: string) => {
|
||||||
|
return contractFollowersIds.includes(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldGetNotification = (
|
||||||
|
userId: string,
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
sourceUser.id != userId &&
|
||||||
|
!Object.keys(userToReasonTexts).includes(userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyContractFollowers = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
for (const userId of contractFollowersIds) {
|
||||||
|
if (shouldGetNotification(userId, userToReasonTexts))
|
||||||
|
userToReasonTexts[userId] = {
|
||||||
|
reason: 'you_follow_contract',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyContractCreator = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
||||||
|
stillFollowingContract(sourceContract.creatorId)
|
||||||
|
)
|
||||||
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
|
reason: 'on_users_contract',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherAnswerersOnContract = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
const answers = await getValues<Answer>(
|
const answers = await getValues<Answer>(
|
||||||
firestore
|
firestore
|
||||||
|
@ -194,7 +294,10 @@ export const createNotification = async (
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
recipientUserIds.forEach((userId) => {
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
if (
|
||||||
|
shouldGetNotification(userId, userToReasonTexts) &&
|
||||||
|
stillFollowingContract(userId)
|
||||||
|
)
|
||||||
userToReasonTexts[userId] = {
|
userToReasonTexts[userId] = {
|
||||||
reason: 'on_contract_with_users_answer',
|
reason: 'on_contract_with_users_answer',
|
||||||
}
|
}
|
||||||
|
@ -202,8 +305,7 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyOtherCommentersOnContract = async (
|
const notifyOtherCommentersOnContract = async (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
) => {
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<Comment>(
|
||||||
firestore
|
firestore
|
||||||
|
@ -213,7 +315,10 @@ export const createNotification = async (
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
recipientUserIds.forEach((userId) => {
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
if (
|
||||||
|
shouldGetNotification(userId, userToReasonTexts) &&
|
||||||
|
stillFollowingContract(userId)
|
||||||
|
)
|
||||||
userToReasonTexts[userId] = {
|
userToReasonTexts[userId] = {
|
||||||
reason: 'on_contract_with_users_comment',
|
reason: 'on_contract_with_users_comment',
|
||||||
}
|
}
|
||||||
|
@ -221,8 +326,7 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyBettorsOnContract = async (
|
const notifyBettorsOnContract = async (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
) => {
|
||||||
const betsSnap = await firestore
|
const betsSnap = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/bets`)
|
.collection(`contracts/${sourceContract.id}/bets`)
|
||||||
|
@ -240,84 +344,86 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipientUserIds.forEach((userId) => {
|
recipientUserIds.forEach((userId) => {
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
if (
|
||||||
|
shouldGetNotification(userId, userToReasonTexts) &&
|
||||||
|
stillFollowingContract(userId)
|
||||||
|
)
|
||||||
userToReasonTexts[userId] = {
|
userToReasonTexts[userId] = {
|
||||||
reason: 'on_contract_with_users_shares_in',
|
reason: 'on_contract_with_users_shares_in',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyUserAddedToGroup = (
|
const notifyRepliedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
relatedUserId: string
|
relatedUserId: string,
|
||||||
|
relatedSourceType: notification_source_types
|
||||||
) => {
|
) => {
|
||||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'added_you_to_group',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
userId: string
|
|
||||||
) => {
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'unique_bettors_on_your_contract',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getUsersToNotify = async () => {
|
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
|
||||||
if (sourceType === 'follow' && recipients?.[0]) {
|
|
||||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
|
||||||
} else if (
|
|
||||||
sourceType === 'group' &&
|
|
||||||
sourceUpdateType === 'created' &&
|
|
||||||
recipients
|
|
||||||
) {
|
|
||||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following functions need sourceContract to be defined.
|
|
||||||
if (!sourceContract) return userToReasonTexts
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sourceType === 'comment' ||
|
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
||||||
sourceType === 'answer' ||
|
stillFollowingContract(relatedUserId)
|
||||||
(sourceType === 'contract' &&
|
|
||||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
|
||||||
) {
|
) {
|
||||||
if (sourceType === 'comment') {
|
if (relatedSourceType === 'comment') {
|
||||||
if (recipients?.[0] && relatedSourceType)
|
userToReasonTexts[relatedUserId] = {
|
||||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
reason: 'reply_to_users_comment',
|
||||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
}
|
||||||
|
} else if (relatedSourceType === 'answer') {
|
||||||
|
userToReasonTexts[relatedUserId] = {
|
||||||
|
reason: 'reply_to_users_answer',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
|
||||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
|
||||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
|
||||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
|
||||||
// Note: the daily bonus won't have a contract attached to it
|
|
||||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
|
||||||
userToReasonTexts,
|
|
||||||
sourceContract.creatorId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return userToReasonTexts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToReasonTexts = await getUsersToNotify()
|
const notifyTaggedUsers = (
|
||||||
|
userToReasonTexts: user_to_reason_texts,
|
||||||
|
userIds: (string | undefined)[]
|
||||||
|
) => {
|
||||||
|
userIds.forEach((id) => {
|
||||||
|
console.log('tagged user: ', id)
|
||||||
|
// Allowing non-following users to get tagged
|
||||||
|
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||||
|
userToReasonTexts[id] = {
|
||||||
|
reason: 'tagged_user',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyLiquidityProviders = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
const liquidityProviders = await firestore
|
||||||
|
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||||
|
.get()
|
||||||
|
const liquidityProvidersIds = uniq(
|
||||||
|
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||||
|
)
|
||||||
|
liquidityProvidersIds.forEach((userId) => {
|
||||||
|
if (
|
||||||
|
shouldGetNotification(userId, userToReasonTexts) &&
|
||||||
|
stillFollowingContract(userId)
|
||||||
|
) {
|
||||||
|
userToReasonTexts[userId] = {
|
||||||
|
reason: 'on_contract_with_users_shares_in',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
|
|
||||||
|
if (sourceType === 'comment') {
|
||||||
|
if (repliedUserId && relatedSourceType)
|
||||||
|
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
||||||
|
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
||||||
|
}
|
||||||
|
await notifyContractCreator(userToReasonTexts)
|
||||||
|
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
||||||
|
await notifyLiquidityProviders(userToReasonTexts)
|
||||||
|
await notifyBettorsOnContract(userToReasonTexts)
|
||||||
|
await notifyOtherCommentersOnContract(userToReasonTexts)
|
||||||
|
// if they weren't added previously, add them now
|
||||||
|
await notifyContractFollowers(userToReasonTexts)
|
||||||
|
|
||||||
await createUsersNotifications(userToReasonTexts)
|
await createUsersNotifications(userToReasonTexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -565,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))
|
||||||
|
}
|
||||||
|
|
83
functions/src/create-post.ts
Normal file
83
functions/src/create-post.ts
Normal 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)
|
||||||
|
}
|
|
@ -1,14 +1,8 @@
|
||||||
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 {
|
import { PrivateUser, User } from '../../common/user'
|
||||||
MANIFOLD_AVATAR_URL,
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
MANIFOLD_USERNAME,
|
|
||||||
PrivateUser,
|
|
||||||
User,
|
|
||||||
} from '../../common/user'
|
|
||||||
import { getUser, getUserByUsername, getValues, isProd } from './utils'
|
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import {
|
import {
|
||||||
cleanDisplayName,
|
cleanDisplayName,
|
||||||
|
@ -22,12 +16,8 @@ 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 {
|
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
} from '../../common/antes'
|
|
||||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from 'common/economy'
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
deviceToken: z.string().optional(),
|
deviceToken: z.string().optional(),
|
||||||
|
@ -126,42 +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)),
|
|
||||||
})
|
|
||||||
const manifoldAccount = isProd()
|
|
||||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
|
|
||||||
if (slug === 'welcome') {
|
|
||||||
const welcomeCommentDoc = firestore
|
|
||||||
.collection(`groups/${group.id}/comments`)
|
|
||||||
.doc()
|
|
||||||
await welcomeCommentDoc.create({
|
|
||||||
id: welcomeCommentDoc.id,
|
|
||||||
groupId: group.id,
|
|
||||||
userId: manifoldAccount,
|
|
||||||
text: `Welcome, @${user.username} aka ${user.name}!`,
|
|
||||||
createdTime: Date.now(),
|
|
||||||
userName: 'Manifold Markets',
|
|
||||||
userUsername: MANIFOLD_USERNAME,
|
|
||||||
userAvatarUrl: MANIFOLD_AVATAR_URL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -444,7 +444,7 @@
|
||||||
style="
|
style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a>.
|
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -1,84 +1,91 @@
|
||||||
<!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>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<title>Market answer</title>
|
|
||||||
|
|
||||||
<style type="text/css">
|
<head>
|
||||||
img {
|
<meta name="viewport" content="width=device-width" />
|
||||||
max-width: 100%;
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market answer</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
h1 {
|
||||||
-webkit-font-smoothing: antialiased;
|
font-weight: 800 !important;
|
||||||
-webkit-text-size-adjust: none;
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100%;
|
|
||||||
line-height: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.content {
|
||||||
background-color: #f6f6f6;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 640px) {
|
.content-wrap {
|
||||||
body {
|
padding: 10px !important;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 22px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.content-wrap {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.invoice {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body
|
.invoice {
|
||||||
itemscope
|
width: 100% !important;
|
||||||
itemtype="http://schema.org/EmailMessage"
|
}
|
||||||
style="
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body 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"
|
</td>
|
||||||
style="height: auto"
|
</tr>
|
||||||
alt="Manifold Markets"
|
<tr style="
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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 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>
|
||||||
>
|
<img src="{{avatarUrl}}" width="30" height="30" style="
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
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}}
|
||||||
/>
|
</div>
|
||||||
{{name}}
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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"
|
</div>
|
||||||
>{{answer}}</span
|
</td>
|
||||||
>
|
</tr>
|
||||||
</div>
|
<tr style="
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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="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 href="{{marketUrl}}" target="_blank" style="
|
||||||
<a
|
|
||||||
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,38 +301,29 @@
|
||||||
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
|
</a>
|
||||||
></span
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
>
|
</div>
|
||||||
</a>
|
</td>
|
||||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
</tr>
|
||||||
</div>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer" style="
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div
|
|
||||||
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"
|
Questions? Come ask in
|
||||||
valign="top"
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
>
|
|
||||||
Questions? Come ask in
|
|
||||||
<a
|
|
||||||
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>
|
||||||
>.
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td
|
</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"
|
</tr>
|
||||||
></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</table>
|
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
|
@ -1,84 +1,91 @@
|
||||||
<!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>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<title>Market closed</title>
|
|
||||||
|
|
||||||
<style type="text/css">
|
<head>
|
||||||
img {
|
<meta name="viewport" content="width=device-width" />
|
||||||
max-width: 100%;
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market closed</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
h1 {
|
||||||
-webkit-font-smoothing: antialiased;
|
font-weight: 800 !important;
|
||||||
-webkit-text-size-adjust: none;
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100%;
|
|
||||||
line-height: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.content {
|
||||||
background-color: #f6f6f6;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 640px) {
|
.content-wrap {
|
||||||
body {
|
padding: 10px !important;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 22px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.content-wrap {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.invoice {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body
|
.invoice {
|
||||||
itemscope
|
width: 100% !important;
|
||||||
itemtype="http://schema.org/EmailMessage"
|
}
|
||||||
style="
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body 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"
|
</td>
|
||||||
style="height: auto"
|
</tr>
|
||||||
alt="Manifold Markets"
|
<tr style="
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
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
|
||||||
>
|
</td>
|
||||||
You asked
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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>
|
||||||
>
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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
|
||||||
>
|
</h2>
|
||||||
Market closed
|
</td>
|
||||||
</h2>
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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 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}},
|
||||||
>
|
<br style="
|
||||||
Hi {{name}},
|
|
||||||
<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;
|
||||||
"
|
" />
|
||||||
/>
|
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 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;
|
||||||
"
|
" />
|
||||||
/>
|
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 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;
|
||||||
"
|
" />
|
||||||
/>
|
Manifold Team
|
||||||
Manifold Team
|
<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;
|
||||||
"
|
" />
|
||||||
/>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
<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="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 href="{{url}}" target="_blank" style="
|
||||||
<a
|
|
||||||
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,38 +419,29 @@
|
||||||
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
|
</a>
|
||||||
></span
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
>
|
</div>
|
||||||
</a>
|
</td>
|
||||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
</tr>
|
||||||
</div>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer" style="
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div
|
|
||||||
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"
|
Questions? Come ask in
|
||||||
valign="top"
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
>
|
|
||||||
Questions? Come ask in
|
|
||||||
<a
|
|
||||||
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>
|
||||||
>.
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td
|
</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"
|
</tr>
|
||||||
></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</table>
|
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
|
@ -1,84 +1,91 @@
|
||||||
<!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>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<title>Market comment</title>
|
|
||||||
|
|
||||||
<style type="text/css">
|
<head>
|
||||||
img {
|
<meta name="viewport" content="width=device-width" />
|
||||||
max-width: 100%;
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market comment</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
h1 {
|
||||||
-webkit-font-smoothing: antialiased;
|
font-weight: 800 !important;
|
||||||
-webkit-text-size-adjust: none;
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100%;
|
|
||||||
line-height: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.content {
|
||||||
background-color: #f6f6f6;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 640px) {
|
.content-wrap {
|
||||||
body {
|
padding: 10px !important;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 22px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.content-wrap {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.invoice {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body
|
.invoice {
|
||||||
itemscope
|
width: 100% !important;
|
||||||
itemtype="http://schema.org/EmailMessage"
|
}
|
||||||
style="
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body 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"
|
</td>
|
||||||
style="height: auto"
|
</tr>
|
||||||
alt="Manifold Markets"
|
<tr style="
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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 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>
|
||||||
>
|
<img src="{{commentorAvatarUrl}}" width="30" height="30" style="
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
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>
|
||||||
/>
|
{{betDescription}}
|
||||||
<span style="font-weight: bold"
|
</div>
|
||||||
>{{commentorName}}</span
|
</td>
|
||||||
>
|
</tr>
|
||||||
{{betDescription}}
|
<tr style="
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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"
|
</div>
|
||||||
>{{comment}}</span
|
</td>
|
||||||
>
|
</tr>
|
||||||
</div>
|
<tr style="
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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="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 href="{{marketUrl}}" target="_blank" style="
|
||||||
<a
|
|
||||||
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,38 +301,29 @@
|
||||||
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
|
</a>
|
||||||
></span
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
>
|
</div>
|
||||||
</a>
|
</td>
|
||||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
</tr>
|
||||||
</div>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer" style="
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div
|
|
||||||
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"
|
Questions? Come ask in
|
||||||
valign="top"
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
>
|
|
||||||
Questions? Come ask in
|
|
||||||
<a
|
|
||||||
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>
|
||||||
>.
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td
|
</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"
|
</tr>
|
||||||
></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</table>
|
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
|
@ -1,84 +1,91 @@
|
||||||
<!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>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<title>Market resolved</title>
|
|
||||||
|
|
||||||
<style type="text/css">
|
<head>
|
||||||
img {
|
<meta name="viewport" content="width=device-width" />
|
||||||
max-width: 100%;
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market resolved</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
h1 {
|
||||||
-webkit-font-smoothing: antialiased;
|
font-weight: 800 !important;
|
||||||
-webkit-text-size-adjust: none;
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100%;
|
|
||||||
line-height: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.content {
|
||||||
background-color: #f6f6f6;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 640px) {
|
.content-wrap {
|
||||||
body {
|
padding: 10px !important;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
font-weight: 800 !important;
|
|
||||||
margin: 20px 0 5px !important;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 22px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.content-wrap {
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
.invoice {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body
|
.invoice {
|
||||||
itemscope
|
width: 100% !important;
|
||||||
itemtype="http://schema.org/EmailMessage"
|
}
|
||||||
style="
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body 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"
|
</td>
|
||||||
style="height: auto"
|
</tr>
|
||||||
alt="Manifold Markets"
|
<tr style="
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
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
|
||||||
>
|
</td>
|
||||||
{{creatorName}} asked
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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>
|
||||||
>
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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;
|
||||||
|
@ -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}}
|
||||||
>
|
</h2>
|
||||||
Resolved {{outcome}}
|
</td>
|
||||||
</h2>
|
</tr>
|
||||||
</td>
|
<tr style="
|
||||||
</tr>
|
|
||||||
<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 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}},
|
||||||
>
|
<br style="
|
||||||
Dear {{name}},
|
|
||||||
<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;
|
||||||
"
|
" />
|
||||||
/>
|
A market you bet in has been resolved!
|
||||||
A market you bet in has been resolved!
|
<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 investment was
|
||||||
Your investment was
|
<span style="font-weight: bold">{{investment}}</span>.
|
||||||
<span style="font-weight: bold"
|
<br style="
|
||||||
>M$ {{investment}}</span
|
|
||||||
>.
|
|
||||||
<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">{{payout}}</span>.
|
||||||
<span style="font-weight: bold"
|
<br style="
|
||||||
>M$ {{payout}}</span
|
|
||||||
>.
|
|
||||||
<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 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;
|
||||||
"
|
" />
|
||||||
/>
|
Manifold Team
|
||||||
Manifold Team
|
<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;
|
||||||
"
|
" />
|
||||||
/>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
<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="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 href="{{url}}" target="_blank" style="
|
||||||
<a
|
|
||||||
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,38 +434,29 @@
|
||||||
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
|
</a>
|
||||||
></span
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
>
|
</div>
|
||||||
</a>
|
</td>
|
||||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
</tr>
|
||||||
</div>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer" style="
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div
|
|
||||||
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"
|
Questions? Come ask in
|
||||||
valign="top"
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
>
|
|
||||||
Questions? Come ask in
|
|
||||||
<a
|
|
||||||
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>
|
||||||
>.
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td
|
</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"
|
</tr>
|
||||||
></td>
|
</table>
|
||||||
</tr>
|
</body>
|
||||||
</table>
|
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
|
@ -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,
|
||||||
|
@ -54,21 +53,28 @@ 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)}${creatorPayoutText}`,
|
payout: displayedPayout + creatorPayoutText,
|
||||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
}
|
}
|
||||||
|
@ -116,7 +122,9 @@ const toDisplayResolution = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
const { resolutionValue } = contract
|
const { resolution, resolutionValue } = contract
|
||||||
|
|
||||||
|
if (resolution === 'CANCEL') return 'N/A'
|
||||||
|
|
||||||
return resolutionValue
|
return resolutionValue
|
||||||
? formatLargeNumber(resolutionValue)
|
? formatLargeNumber(resolutionValue)
|
||||||
|
@ -178,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).
|
||||||
|
|
||||||
|
@ -313,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'
|
||||||
|
@ -330,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),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
36
functions/src/follow-market.ts
Normal file
36
functions/src/follow-market.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const addUserToContractFollowers = async (
|
||||||
|
contractId: string,
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
const followerDoc = await firestore
|
||||||
|
.collection(`contracts/${contractId}/follows`)
|
||||||
|
.doc(userId)
|
||||||
|
.get()
|
||||||
|
if (followerDoc.exists) return
|
||||||
|
await firestore
|
||||||
|
.collection(`contracts/${contractId}/follows`)
|
||||||
|
.doc(userId)
|
||||||
|
.set({
|
||||||
|
id: userId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeUserFromContractFollowers = async (
|
||||||
|
contractId: string,
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
const followerDoc = await firestore
|
||||||
|
.collection(`contracts/${contractId}/follows`)
|
||||||
|
.doc(userId)
|
||||||
|
.get()
|
||||||
|
if (!followerDoc.exists) return
|
||||||
|
await firestore
|
||||||
|
.collection(`contracts/${contractId}/follows`)
|
||||||
|
.doc(userId)
|
||||||
|
.delete()
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -21,14 +21,15 @@ 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'
|
||||||
export * from './weekly-markets-emails'
|
export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
|
export * from './reset-weekly-emails-flag'
|
||||||
|
export * from './on-update-contract-follow'
|
||||||
|
export * from './on-update-like'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -69,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)
|
||||||
|
@ -94,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,
|
||||||
|
@ -117,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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
|
|
||||||
export const onCreateAnswer = functions.firestore
|
export const onCreateAnswer = functions.firestore
|
||||||
|
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
|
||||||
|
|
||||||
const answerCreator = await getUser(answer.userId)
|
const answerCreator = await getUser(answer.userId)
|
||||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||||
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
await createNotification(
|
|
||||||
answer.id,
|
answer.id,
|
||||||
'answer',
|
'answer',
|
||||||
'created',
|
'created',
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId,
|
||||||
answer.text,
|
answer.text,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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',
|
contract,
|
||||||
'created',
|
result.txn.amount,
|
||||||
fromUser,
|
eventId + '-unique-bettor-bonus'
|
||||||
eventId + '-bonus',
|
|
||||||
result.txn.amount + '',
|
|
||||||
{
|
|
||||||
contract,
|
|
||||||
slug: contract.slug,
|
|
||||||
title: contract.question,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,12 @@ 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 { createNotification } 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'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -35,6 +39,8 @@ export const onCreateCommentOnContract = functions
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contract.id, commentCreator.id)
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
|
@ -57,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>(
|
||||||
|
@ -77,24 +87,28 @@ export const onCreateCommentOnContract = functions
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
const recipients = uniq(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
compact([...parseMentions(comment.content), repliedUserId])
|
|
||||||
)
|
|
||||||
|
|
||||||
await createNotification(
|
|
||||||
comment.id,
|
comment.id,
|
||||||
'comment',
|
'comment',
|
||||||
'created',
|
'created',
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
{ contract, relatedSourceType, recipients }
|
contract,
|
||||||
|
{
|
||||||
|
relatedSourceType,
|
||||||
|
repliedUserId,
|
||||||
|
taggedUserIds: compact(parseMentions(comment.content)),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
|
||||||
contract.creatorId,
|
uniq([
|
||||||
...comments.map((comment) => comment.userId),
|
contract.creatorId,
|
||||||
]).filter((id) => id !== comment.userId)
|
...comments.map((comment) => comment.userId),
|
||||||
|
]).filter((id) => id !== comment.userId),
|
||||||
|
contractId
|
||||||
|
)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientUserIds.map((userId) =>
|
recipientUserIds.map((userId) =>
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -18,6 +19,7 @@ export const onCreateContract = functions
|
||||||
|
|
||||||
const desc = contract.description as JSONContent
|
const desc = contract.description as JSONContent
|
||||||
const mentioned = parseMentions(desc)
|
const mentioned = parseMentions(desc)
|
||||||
|
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,7 +1,13 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser, log } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
import { FIXED_ANTE } from '../../common/economy'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from '../../common/antes'
|
||||||
|
|
||||||
export const onCreateLiquidityProvision = functions.firestore
|
export const onCreateLiquidityProvision = functions.firestore
|
||||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||||
|
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
|
|
||||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
if (
|
||||||
|
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||||
|
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
|
||||||
|
liquidity.amount === FIXED_ANTE
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
|
||||||
|
|
||||||
const contract = await getContract(liquidity.contractId)
|
const contract = await getContract(liquidity.contractId)
|
||||||
if (!contract)
|
if (!contract)
|
||||||
|
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
|
|
||||||
const liquidityProvider = await getUser(liquidity.userId)
|
const liquidityProvider = await getUser(liquidity.userId)
|
||||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||||
|
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
46
functions/src/on-update-contract-follow.ts
Normal file
46
functions/src/on-update-contract-follow.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
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
|
||||||
|
.document('contracts/{contractId}/follows/{userId}')
|
||||||
|
.onDelete(async (change, context) => {
|
||||||
|
const { contractId } = context.params as {
|
||||||
|
contractId: string
|
||||||
|
}
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const contract = await firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.doc(contractId)
|
||||||
|
.get()
|
||||||
|
if (!contract.exists) throw new Error('Could not find contract')
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.doc(contractId)
|
||||||
|
.update({
|
||||||
|
followerCount: FieldValue.increment(-1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onCreateContractFollow = functions.firestore
|
||||||
|
.document('contracts/{contractId}/follows/{userId}')
|
||||||
|
.onCreate(async (change, context) => {
|
||||||
|
const { contractId } = context.params as {
|
||||||
|
contractId: string
|
||||||
|
}
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const contract = await firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.doc(contractId)
|
||||||
|
.get()
|
||||||
|
if (!contract.exists) throw new Error('Could not find contract')
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.doc(contractId)
|
||||||
|
.update({
|
||||||
|
followerCount: FieldValue.increment(1),
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
|
@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
|
||||||
resolutionText = `${contract.resolutionValue}`
|
resolutionText = `${contract.resolutionValue}`
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'resolved',
|
'resolved',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
resolutionText,
|
resolutionText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.description !== contract.description
|
previousValue.question !== contract.question
|
||||||
) {
|
) {
|
||||||
let sourceText = ''
|
let sourceText = ''
|
||||||
if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
|
if (
|
||||||
|
previousValue.closeTime !== contract.closeTime &&
|
||||||
|
contract.closeTime
|
||||||
|
) {
|
||||||
sourceText = contract.closeTime.toString()
|
sourceText = contract.closeTime.toString()
|
||||||
else {
|
} else if (previousValue.question !== contract.question) {
|
||||||
const oldTrimmedDescription = previousValue.description.trim()
|
sourceText = contract.question
|
||||||
const newTrimmedDescription = contract.description.trim()
|
|
||||||
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
|
|
||||||
else
|
|
||||||
sourceText = newTrimmedDescription
|
|
||||||
.split(oldTrimmedDescription)[1]
|
|
||||||
.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'updated',
|
'updated',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
sourceText,
|
sourceText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
109
functions/src/on-update-like.ts
Normal file
109
functions/src/on-update-like.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -5,10 +5,10 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
import { createReferralNotification } from './create-notification'
|
import { createReferralNotification } from './create-notification'
|
||||||
import { ReferralTxn } from '../../common/txn'
|
import { ReferralTxn } from '../../common/txn'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from '../../common/bet'
|
||||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||||
import { Group } from 'common/group'
|
import { Group } from '../../common/group'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from '../../common/economy'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateUser = functions.firestore
|
export const onUpdateUser = functions.firestore
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
|
||||||
import { floatingEqual } from '../../common/util/math'
|
import { floatingEqual } from '../../common/util/math'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
return { betId: betDoc.id, makers, newBet }
|
return { betId: betDoc.id, makers, newBet }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contractId, auth.uid)
|
||||||
|
|
||||||
log('Main transaction finished.')
|
log('Main transaction finished.')
|
||||||
|
|
||||||
if (result.newBet.amount !== 0) {
|
if (result.newBet.amount !== 0) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -9,13 +9,16 @@ const firestore = admin.firestore()
|
||||||
|
|
||||||
export const resetBettingStreaksForUsers = functions.pubsub
|
export const resetBettingStreaksForUsers = functions.pubsub
|
||||||
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||||
.timeZone('utc')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await resetBettingStreaksInternal()
|
await resetBettingStreaksInternal()
|
||||||
})
|
})
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
24
functions/src/reset-weekly-emails-flag.ts
Normal file
24
functions/src/reset-weekly-emails-flag.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { getAllPrivateUsers } from './utils'
|
||||||
|
|
||||||
|
export const resetWeeklyEmailsFlag = functions
|
||||||
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
||||||
|
.pubsub.schedule('0 7 * * 1')
|
||||||
|
.timeZone('Etc/UTC')
|
||||||
|
.onRun(async () => {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
// get all users that haven't unsubscribed from weekly emails
|
||||||
|
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||||
|
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||||
|
})
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (user) => {
|
||||||
|
return firestore.collection('private-users').doc(user.id).update({
|
||||||
|
weeklyTrendingEmailSent: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
import { getValues } from '../utils'
|
||||||
|
import { Contract } from 'common/lib/contract'
|
||||||
|
import { Comment } from 'common/lib/comment'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
import { Bet } from 'common/lib/bet'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from 'common/lib/antes'
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function backfillContractFollowers() {
|
||||||
|
console.log('Backfilling contract followers')
|
||||||
|
const contracts = await getValues<Contract>(
|
||||||
|
firestore.collection('contracts').where('isResolved', '==', false)
|
||||||
|
)
|
||||||
|
let count = 0
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const comments = await getValues<Comment>(
|
||||||
|
firestore.collection('contracts').doc(contract.id).collection('comments')
|
||||||
|
)
|
||||||
|
const commenterIds = uniq(comments.map((comment) => comment.userId))
|
||||||
|
const betsSnap = await firestore
|
||||||
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
|
.get()
|
||||||
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
|
// filter bets for only users that have an amount invested still
|
||||||
|
const bettorIds = uniq(bets.map((bet) => bet.userId))
|
||||||
|
const liquidityProviders = await firestore
|
||||||
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
|
.get()
|
||||||
|
const liquidityProvidersIds = uniq(
|
||||||
|
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||||
|
// exclude free market liquidity provider
|
||||||
|
).filter(
|
||||||
|
(id) =>
|
||||||
|
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||||
|
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
)
|
||||||
|
const followerIds = uniq([
|
||||||
|
...commenterIds,
|
||||||
|
...bettorIds,
|
||||||
|
...liquidityProvidersIds,
|
||||||
|
contract.creatorId,
|
||||||
|
])
|
||||||
|
for (const followerId of followerIds) {
|
||||||
|
await firestore
|
||||||
|
.collection(`contracts/${contract.id}/follows`)
|
||||||
|
.doc(followerId)
|
||||||
|
.set({ id: followerId, createdTime: Date.now() })
|
||||||
|
}
|
||||||
|
// Perhaps handled by the trigger?
|
||||||
|
// const followerCount = followerIds.length
|
||||||
|
// await firestore
|
||||||
|
// .collection(`contracts`)
|
||||||
|
// .doc(contract.id)
|
||||||
|
// .update({ followerCount: followerCount })
|
||||||
|
count += 1
|
||||||
|
if (count % 100 === 0) {
|
||||||
|
console.log(`${count} contracts processed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
backfillContractFollowers()
|
||||||
|
.then(() => process.exit())
|
||||||
|
.catch(console.log)
|
||||||
|
}
|
39
functions/src/scripts/backfill-unique-bettors.ts
Normal file
39
functions/src/scripts/backfill-unique-bettors.ts
Normal 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()
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal file
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal 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))
|
||||||
|
}
|
122
functions/src/scripts/update-groups.ts
Normal file
122
functions/src/scripts/update-groups.ts
Normal 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()
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
import { removeUserFromContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return { newBet, makers }
|
return { newBet, makers, maxShares, soldShares }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (result.maxShares === result.soldShares) {
|
||||||
|
await removeUserFromContractFollowers(contractId, auth.uid)
|
||||||
|
}
|
||||||
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||||
log('Share redemption transaction finished.')
|
log('Share redemption transaction finished.')
|
||||||
|
|
|
@ -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}.`)
|
||||||
|
|
|
@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||||
)
|
)
|
||||||
|
else if (type === 'weekly-trending')
|
||||||
|
res.send(
|
||||||
|
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
|
||||||
|
)
|
||||||
else res.send(`${name}, you have been unsubscribed.`)
|
else res.send(`${name}, you have been unsubscribed.`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,8 +12,8 @@ const firestore = admin.firestore()
|
||||||
|
|
||||||
export const updateLoans = functions
|
export const updateLoans = functions
|
||||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
// Run every Monday.
|
// Run every day at midnight.
|
||||||
.pubsub.schedule('0 0 * * 1')
|
.pubsub.schedule('0 0 * * *')
|
||||||
.timeZone('America/Los_Angeles')
|
.timeZone('America/Los_Angeles')
|
||||||
.onRun(updateLoansCore)
|
.onRun(updateLoansCore)
|
||||||
|
|
||||||
|
@ -79,9 +79,13 @@ async function updateLoansCore() {
|
||||||
const today = new Date().toDateString().replace(' ', '-')
|
const today = new Date().toDateString().replace(' ', '-')
|
||||||
const key = `loan-notifications-${today}`
|
const key = `loan-notifications-${today}`
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
userPayouts.map(({ user, payout }) =>
|
userPayouts
|
||||||
createLoanIncomeNotification(user, key, payout)
|
// Don't send a notification if the payout is < M$1,
|
||||||
)
|
// because a M$0 loan is confusing.
|
||||||
|
.filter(({ payout }) => payout >= 1)
|
||||||
|
.map(({ user, payout }) =>
|
||||||
|
createLoanIncomeNotification(user, key, payout)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
log('Notifications sent!')
|
log('Notifications sent!')
|
||||||
|
|
|
@ -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,16 +41,36 @@ 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
|
||||||
const contractBets = betsByContract[contract.id] ?? []
|
.filter((contract) => contract.id)
|
||||||
return {
|
.map((contract) => {
|
||||||
doc: firestore.collection('contracts').doc(contract.id),
|
const contractBets = betsByContract[contract.id] ?? []
|
||||||
fields: {
|
const descendingBets = sortBy(
|
||||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
contractBets,
|
||||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
(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 {
|
||||||
|
doc: firestore.collection('contracts').doc(contract.id),
|
||||||
|
fields: {
|
||||||
|
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||||
|
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||||
|
...cpmmFields,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
await writeAsync(firestore, contractUpdates)
|
await writeAsync(firestore, contractUpdates)
|
||||||
log(`Updated metrics for ${contracts.length} contracts.`)
|
log(`Updated metrics for ${contracts.length} contracts.`)
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
|
|
|
@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateStats = functions
|
export const updateStats = functions
|
||||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 60 minutes')
|
.pubsub.schedule('every 60 minutes')
|
||||||
.onRun(updateStatsCore)
|
.onRun(updateStatsCore)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,24 @@ 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'] })
|
||||||
// every Monday at 12pm PT (UTC -07:00)
|
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
||||||
.pubsub.schedule('0 19 * * 1')
|
.pubsub.schedule('* 19 * * 1')
|
||||||
.timeZone('utc')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await sendTrendingMarketsEmailsToAllUsers()
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
})
|
})
|
||||||
|
@ -34,20 +42,38 @@ 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 !user.unsubscribedFromWeeklyTrendingEmails
|
return (
|
||||||
|
!user.unsubscribedFromWeeklyTrendingEmails &&
|
||||||
|
!user.weeklyTrendingEmailSent
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
log(
|
||||||
|
'Sending weekly trending emails to',
|
||||||
|
privateUsersToSendEmailsTo.length,
|
||||||
|
'users'
|
||||||
|
)
|
||||||
const trendingContracts = (await getTrendingContracts())
|
const trendingContracts = (await getTrendingContracts())
|
||||||
.filter(
|
.filter(
|
||||||
(contract) =>
|
(contract) =>
|
||||||
!(
|
!(
|
||||||
contract.question.toLowerCase().includes('trump') &&
|
contract.question.toLowerCase().includes('trump') &&
|
||||||
contract.question.toLowerCase().includes('president')
|
contract.question.toLowerCase().includes('president')
|
||||||
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS
|
) &&
|
||||||
|
(contract?.closeTime ?? 0) > Date.now() + DAY_MS &&
|
||||||
|
!contract.groupSlugs?.includes('manifold-features') &&
|
||||||
|
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
||||||
)
|
)
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
|
log(
|
||||||
|
`Found ${trendingContracts.length} trending contracts:\n`,
|
||||||
|
trendingContracts.map((c) => c.question).join('\n ')
|
||||||
|
)
|
||||||
|
|
||||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||||
if (!privateUser.email) {
|
if (!privateUser.email) {
|
||||||
log(`No email for ${privateUser.username}`)
|
log(`No email for ${privateUser.username}`)
|
||||||
|
@ -70,12 +96,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
if (!user) continue
|
if (!user) continue
|
||||||
|
|
||||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||||
|
await firestore.collection('private-users').doc(user.id).update({
|
||||||
|
weeklyTrendingEmailSent: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||||
|
const rng = createRNG(seed)
|
||||||
|
|
||||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
shuffle(contracts, rng)
|
||||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
|
||||||
shuffle(contracts, createRNG(seed))
|
|
||||||
return contracts.slice(0, count)
|
return contracts.slice(0, count)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 • M$ 123 pool',
|
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
||||||
|
|
81
og-image/api/_lib/template-css.ts
Normal file
81
og-image/api/_lib/template-css.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
16
package.json
16
package.json
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||||
useState<notification_subscribe_types>('all')
|
useState<notification_subscribe_types>('all')
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
function NotificationSettingLine(props: {
|
||||||
label: string
|
label: string | React.ReactNode
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { label, highlight } = props
|
const { label, highlight, onClick } = props
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'my-1 gap-1 text-gray-300',
|
||||||
|
highlight && '!text-black',
|
||||||
|
onClick ? 'cursor-pointer' : ''
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||||
{label}
|
{label}
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
||||||
toggleClassName={'w-24'}
|
toggleClassName={'w-24'}
|
||||||
/>
|
/>
|
||||||
<div className={'mt-4 text-sm'}>
|
<div className={'mt-4 text-sm'}>
|
||||||
<div>
|
<Col className={''}>
|
||||||
<div className={''}>
|
<Row className={'my-1'}>
|
||||||
You will receive notifications for:
|
You will receive notifications for these general events:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
label={"Resolution of questions you've interacted with"}
|
<NotificationSettingLine
|
||||||
highlight={notificationSettings !== 'none'}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={"Income & referral bonuses you've received"}
|
||||||
<NotificationSettingLine
|
/>
|
||||||
highlight={notificationSettings !== 'none'}
|
<Row className={'my-1'}>
|
||||||
label={'Activity on your own questions, comments, & answers'}
|
You will receive new comment, answer, & resolution notifications on
|
||||||
/>
|
questions:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
highlight={notificationSettings !== 'none'}
|
<NotificationSettingLine
|
||||||
label={"Activity on questions you're betting on"}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={
|
||||||
<NotificationSettingLine
|
<span>
|
||||||
highlight={notificationSettings !== 'none'}
|
That <span className={'font-bold'}>you watch </span>- you
|
||||||
label={"Income & referral bonuses you've received"}
|
auto-watch questions if:
|
||||||
/>
|
</span>
|
||||||
<NotificationSettingLine
|
}
|
||||||
label={"Activity on questions you've ever bet or commented on"}
|
onClick={() => setShowModal(true)}
|
||||||
highlight={notificationSettings === 'all'}
|
/>
|
||||||
/>
|
<Col
|
||||||
</div>
|
className={clsx(
|
||||||
</div>
|
'mb-2 ml-8',
|
||||||
|
'gap-1 text-gray-300',
|
||||||
|
notificationSettings !== 'none' && '!text-black'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row>• You create it</Row>
|
||||||
|
<Row>• You bet, comment on, or answer it</Row>
|
||||||
|
<Row>• You add liquidity to it</Row>
|
||||||
|
<Row>
|
||||||
|
• If you select 'Less' and you've commented on or answered a
|
||||||
|
question, you'll only receive notification on direct replies to
|
||||||
|
your comments or answers
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
<div className={'mt-4'}>Email Notifications</div>
|
||||||
<ChoicesToggleGroup
|
<ChoicesToggleGroup
|
||||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -33,7 +34,8 @@ export function AmountInput(props: {
|
||||||
const isInvalid = !str || isNaN(amount)
|
const isInvalid = !str || isNaN(amount)
|
||||||
onChange(isInvalid ? undefined : amount)
|
onChange(isInvalid ? undefined : amount)
|
||||||
}
|
}
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const isMobile = (width ?? 0) < 768
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group mb-4">
|
<label className="input-group mb-4">
|
||||||
|
@ -50,6 +52,7 @@ export function AmountInput(props: {
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
|
autoFocus={!isMobile}
|
||||||
value={amount ?? ''}
|
value={amount ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onAmountChange(e.target.value)}
|
onChange={(e) => onAmountChange(e.target.value)}
|
||||||
|
@ -81,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
|
||||||
|
@ -91,6 +95,7 @@ export function BuyAmountInput(props: {
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
setError,
|
setError,
|
||||||
|
showSliderOnMobile: showSlider,
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
|
@ -118,15 +123,28 @@ export function BuyAmountInput(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AmountInput
|
<>
|
||||||
amount={amount}
|
<AmountInput
|
||||||
onChange={onAmountChange}
|
amount={amount}
|
||||||
label={ENV_CONFIG.moneyMoniker}
|
onChange={onAmountChange}
|
||||||
error={error}
|
label={ENV_CONFIG.moneyMoniker}
|
||||||
disabled={disabled}
|
error={error}
|
||||||
className={className}
|
disabled={disabled}
|
||||||
inputClassName={inputClassName}
|
className={className}
|
||||||
inputRef={inputRef}
|
inputClassName={inputClassName}
|
||||||
/>
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
@ -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 &&
|
||||||
|
@ -204,7 +209,7 @@ export function AnswerBetPanel(props: {
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<SignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
142
web/components/arrange-home.tsx
Normal file
142
web/components/arrange-home.tsx
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
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
|
||||||
|
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) => {
|
||||||
|
console.log('drag end', 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 bets', 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
if (fbUser) {
|
// Persist to local storage, to reduce login blink next time.
|
||||||
setTokenCookies({
|
// Note: Cap on localStorage size is ~5mb
|
||||||
id: await fbUser.getIdToken(),
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
||||||
refresh: fbUser.refreshToken,
|
} else {
|
||||||
})
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
let current = await getUserAndPrivateUser(fbUser.uid)
|
}
|
||||||
if (!current.user || !current.privateUser) {
|
}, [authUser])
|
||||||
const deviceToken = ensureDeviceToken()
|
|
||||||
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
useEffect(() => {
|
||||||
|
return onIdTokenChanged(
|
||||||
|
auth,
|
||||||
|
async (fbUser) => {
|
||||||
|
if (fbUser) {
|
||||||
|
setUserCookie(JSON.stringify(fbUser.toJSON()))
|
||||||
|
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||||
|
if (!current.user || !current.privateUser) {
|
||||||
|
const deviceToken = ensureDeviceToken()
|
||||||
|
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
||||||
|
setCachedReferralInfoForUser(current.user)
|
||||||
|
}
|
||||||
|
setAuthUser(current)
|
||||||
|
} else {
|
||||||
|
// User logged out; reset to null
|
||||||
|
setUserCookie(undefined)
|
||||||
|
setAuthUser(null)
|
||||||
}
|
}
|
||||||
setAuthUser(current)
|
},
|
||||||
// Persist to local storage, to reduce login blink next time.
|
(e) => {
|
||||||
// Note: Cap on localStorage size is ~5mb
|
console.error(e)
|
||||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
|
|
||||||
setCachedReferralInfoForUser(current.user)
|
|
||||||
} else {
|
|
||||||
// User logged out; reset to null
|
|
||||||
deleteTokenCookies()
|
|
||||||
setAuthUser(null)
|
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}, [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) => {
|
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
|
||||||
setAuthUser((authUser) => {
|
setAuthUser((currAuthUser) =>
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
|
||||||
return { ...authUser!, privateUser: privateUser! }
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
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>
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
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 { 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: {
|
||||||
|
@ -30,23 +32,29 @@ export default function BetButton(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className={clsx('items-center', className)}>
|
<Col className={clsx('items-center', className)}>
|
||||||
<button
|
{user ? (
|
||||||
className={clsx(
|
<Button
|
||||||
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
|
size="lg"
|
||||||
btnClassName
|
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||||
)}
|
onClick={() => setOpen(true)}
|
||||||
onClick={() => setOpen(true)}
|
>
|
||||||
>
|
Bet
|
||||||
Bet
|
</Button>
|
||||||
</button>
|
) : (
|
||||||
|
<BetSignUpPrompt />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
{user && (
|
||||||
{hasYesShares
|
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||||
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
|
{hasYesShares
|
||||||
: hasNoShares
|
? `(${Math.floor(yesShares)} ${
|
||||||
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
isPseudoNumeric ? 'HIGHER' : 'YES'
|
||||||
: ''}
|
})`
|
||||||
</div>
|
: hasNoShares
|
||||||
|
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
|
||||||
export function BetInline(props: {
|
export function BetInline(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
setProbAfter: (probAfter: number) => void
|
setProbAfter: (probAfter: number | undefined) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, className, setProbAfter, onClose } = props
|
const { contract, className, setProbAfter, onClose } = props
|
||||||
|
@ -82,7 +82,7 @@ export function BetInline(props: {
|
||||||
<div className="text-xl">Bet</div>
|
<div className="text-xl">Bet</div>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
className="space-x-0"
|
className="space-x-0"
|
||||||
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||||
selected={outcome}
|
selected={outcome}
|
||||||
onSelect={setOutcome}
|
onSelect={setOutcome}
|
||||||
isPseudoNumeric={isPseudoNumeric}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
|
@ -112,8 +112,13 @@ export function BetInline(props: {
|
||||||
: 'Submit'}
|
: 'Submit'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<SignUpPrompt size="xs" />
|
<BetSignUpPrompt size="xs" />
|
||||||
<button onClick={onClose}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setProbAfter(undefined)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<XIcon className="ml-1 h-6 w-6" />
|
<XIcon className="ml-1 h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { clamp, partition, sum, sumBy } from 'lodash'
|
import { clamp, partition, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
@ -8,8 +8,8 @@ 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,
|
||||||
formatMoneyWithDecimals,
|
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
} from 'common/util/format'
|
} from 'common/util/format'
|
||||||
|
@ -18,7 +18,6 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { Bet, LimitBet } from 'common/bet'
|
import { Bet, LimitBet } from 'common/bet'
|
||||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
|
||||||
import {
|
import {
|
||||||
BinaryOutcomeLabel,
|
BinaryOutcomeLabel,
|
||||||
HigherLabel,
|
HigherLabel,
|
||||||
|
@ -30,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'
|
||||||
|
@ -70,27 +68,32 @@ export function BetPanel(props: {
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<QuickOrLimitBet
|
{user ? (
|
||||||
isLimitOrder={isLimitOrder}
|
<>
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
<QuickOrLimitBet
|
||||||
hideToggle={!user}
|
isLimitOrder={isLimitOrder}
|
||||||
/>
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
<BuyPanel
|
hideToggle={!user}
|
||||||
hidden={isLimitOrder}
|
/>
|
||||||
contract={contract}
|
<BuyPanel
|
||||||
user={user}
|
hidden={isLimitOrder}
|
||||||
unfilledBets={unfilledBets}
|
contract={contract}
|
||||||
/>
|
user={user}
|
||||||
<LimitOrderPanel
|
unfilledBets={unfilledBets}
|
||||||
hidden={!isLimitOrder}
|
/>
|
||||||
contract={contract}
|
<LimitOrderPanel
|
||||||
user={user}
|
hidden={!isLimitOrder}
|
||||||
unfilledBets={unfilledBets}
|
contract={contract}
|
||||||
/>
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
<SignUpPrompt />
|
/>
|
||||||
|
</>
|
||||||
{!user && <PlayMoneyDisclaimer />}
|
) : (
|
||||||
|
<>
|
||||||
|
<BetSignUpPrompt />
|
||||||
|
<PlayMoneyDisclaimer />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{user && unfilledBets.length > 0 && (
|
{user && unfilledBets.length > 0 && (
|
||||||
|
@ -148,7 +151,7 @@ export function SimpleBetPanel(props: {
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
|
||||||
{!user && <PlayMoneyDisclaimer />}
|
{!user && <PlayMoneyDisclaimer />}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -181,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)
|
||||||
|
@ -254,19 +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 totalFees = sum(Object.values(newBet.fees))
|
|
||||||
|
|
||||||
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 bet. \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">
|
||||||
|
@ -280,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}
|
||||||
|
@ -289,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">
|
||||||
|
@ -346,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">
|
||||||
|
@ -564,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
|
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||||
</div>
|
</div>
|
||||||
<ProbabilityOrNumericInput
|
<ProbabilityOrNumericInput
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -575,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
|
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||||
</div>
|
</div>
|
||||||
<ProbabilityOrNumericInput
|
<ProbabilityOrNumericInput
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -598,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">
|
||||||
Max amount<span className="ml-1 text-red-500">*</span>
|
<span>
|
||||||
</div>
|
Max amount<span className="ml-1 text-red-500">*</span>
|
||||||
|
</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}
|
||||||
|
@ -608,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">
|
||||||
|
@ -665,9 +677,9 @@ function LimitOrderPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InfoTooltip
|
{/* <InfoTooltip
|
||||||
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
||||||
/>
|
/> */}
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
@ -689,9 +701,9 @@ function LimitOrderPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InfoTooltip
|
{/* <InfoTooltip
|
||||||
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
||||||
/>
|
/> */}
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
|
|
@ -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,26 +209,27 @@ 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) => (
|
<>
|
||||||
<ContractBets
|
{displayedContracts.map((contract) => (
|
||||||
key={contract.id}
|
<ContractBets
|
||||||
contract={contract}
|
key={contract.id}
|
||||||
bets={contractBets[contract.id] ?? []}
|
contract={contract}
|
||||||
metric={sort === 'profit' ? 'profit' : 'value'}
|
bets={contractBets[contract.id] ?? []}
|
||||||
isYourBets={isYourBets}
|
metric={sort === 'profit' ? 'profit' : 'value'}
|
||||||
|
isYourBets={isYourBets}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
itemsPerPage={CONTRACTS_PER_PAGE}
|
||||||
|
totalItems={filteredContracts.length}
|
||||||
|
setPage={setPage}
|
||||||
/>
|
/>
|
||||||
))
|
</>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
itemsPerPage={CONTRACTS_PER_PAGE}
|
|
||||||
totalItems={filteredContracts.length}
|
|
||||||
setPage={setPage}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
72
web/components/carousel.tsx
Normal file
72
web/components/carousel.tsx
Normal 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 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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,7 @@ export function AcceptChallengeButton(props: {
|
||||||
setErrorText('')
|
setErrorText('')
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
|
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
|
||||||
|
|
||||||
const iAcceptChallenge = () => {
|
const iAcceptChallenge = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
|
@ -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,72 +170,76 @@ 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">
|
||||||
<div>You'll bet:</div>
|
<Col>
|
||||||
<Row
|
<div>You'll bet:</div>
|
||||||
className={
|
<Row
|
||||||
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
|
className={
|
||||||
}
|
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
|
||||||
>
|
|
||||||
<AmountInput
|
|
||||||
amount={challengeInfo.amount || undefined}
|
|
||||||
onChange={(newAmount) =>
|
|
||||||
setChallengeInfo((m: challengeInfo) => {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
amount: newAmount ?? 0,
|
|
||||||
acceptorAmount: editingAcceptorAmount
|
|
||||||
? m.acceptorAmount
|
|
||||||
: newAmount ?? 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
error={undefined}
|
|
||||||
label={'M$'}
|
|
||||||
inputClassName="w-24"
|
|
||||||
/>
|
|
||||||
<span className={''}>on</span>
|
|
||||||
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
|
||||||
</Row>
|
|
||||||
<Row className={'mt-3 max-w-xs justify-end'}>
|
|
||||||
<Button
|
|
||||||
color={'gray-white'}
|
|
||||||
onClick={() =>
|
|
||||||
setChallengeInfo((m: challengeInfo) => {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SwitchVerticalIcon className={'h-6 w-6'} />
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
<Row className={'items-center'}>If they bet:</Row>
|
|
||||||
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
|
||||||
<div className={'w-32 sm:mr-1'}>
|
|
||||||
<AmountInput
|
<AmountInput
|
||||||
amount={challengeInfo.acceptorAmount || undefined}
|
amount={challengeInfo.amount || undefined}
|
||||||
onChange={(newAmount) => {
|
onChange={(newAmount) =>
|
||||||
setEditingAcceptorAmount(true)
|
|
||||||
|
|
||||||
setChallengeInfo((m: challengeInfo) => {
|
setChallengeInfo((m: challengeInfo) => {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
acceptorAmount: newAmount ?? 0,
|
amount: newAmount ?? 0,
|
||||||
|
acceptorAmount: editingAcceptorAmount
|
||||||
|
? m.acceptorAmount
|
||||||
|
: newAmount ?? 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}
|
||||||
error={undefined}
|
error={undefined}
|
||||||
label={'M$'}
|
label={'M$'}
|
||||||
inputClassName="w-24"
|
inputClassName="w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className={''}>on</span>
|
||||||
<span>on</span>
|
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
||||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
</Row>
|
||||||
</Row>
|
<Row className={'mt-3 max-w-xs justify-end'}>
|
||||||
</div>
|
<Button
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={() =>
|
||||||
|
setChallengeInfo((m: challengeInfo) => {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SwitchVerticalIcon className={'h-6 w-6'} />
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
<Row className={'items-center'}>If they bet:</Row>
|
||||||
|
<Row
|
||||||
|
className={'max-w-xs items-center justify-between gap-4 pr-3'}
|
||||||
|
>
|
||||||
|
<div className={'w-32 sm:mr-1'}>
|
||||||
|
<AmountInput
|
||||||
|
amount={challengeInfo.acceptorAmount || undefined}
|
||||||
|
onChange={(newAmount) => {
|
||||||
|
setEditingAcceptorAmount(true)
|
||||||
|
|
||||||
|
setChallengeInfo((m: challengeInfo) => {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
acceptorAmount: newAmount ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
error={undefined}
|
||||||
|
label={'M$'}
|
||||||
|
inputClassName="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>on</span>
|
||||||
|
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
{contract && (
|
{contract && (
|
||||||
<Button
|
<Button
|
||||||
size="2xs"
|
size="2xs"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
|
||||||
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [user.id])
|
|
||||||
|
|
||||||
if (comments == null) {
|
const pageComments = groupConsecutive(getItems(), (c) => {
|
||||||
|
return {
|
||||||
|
contractId: c.contractId,
|
||||||
|
contractQuestion: c.contractQuestion,
|
||||||
|
contractSlug: c.contractSlug,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user