Merge branch 'main' into cpmm

This commit is contained in:
mantikoros 2022-03-02 16:22:16 -05:00
commit a9ccca6458
63 changed files with 4408 additions and 506 deletions

14
common/access.ts Normal file
View File

@ -0,0 +1,14 @@
export function isWhitelisted(email?: string) {
return true
// e.g. return email.endsWith('@theoremone.co') || isAdmin(email)
}
export function isAdmin(email: string) {
const ADMINS = [
'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James
'taowell@gmail.com', // Stephen
'manticmarkets@gmail.com', // Manifold
]
return ADMINS.includes(email)
}

View File

@ -4,6 +4,7 @@ export type Bet = {
contractId: string contractId: string
amount: number // bet size; negative if SELL bet amount: number // bet size; negative if SELL bet
loanAmount?: number
outcome: string outcome: string
shares: number // dynamic parimutuel pool weight; negative if SELL bet shares: number // dynamic parimutuel pool weight; negative if SELL bet
@ -21,3 +22,5 @@ export type Bet = {
createdTime: number createdTime: number
} }
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,4 +1,5 @@
import { Bet } from './bet' import * as _ from 'lodash'
import { Bet, MAX_LOAN_PER_CONTRACT } from './bet'
import { import {
calculateShares, calculateShares,
getProbability, getProbability,
@ -20,6 +21,7 @@ export const getNewBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: FullContract<CPMM, Binary>, contract: FullContract<CPMM, Binary>,
loanAmount: number,
newBetId: string newBetId: string
) => { ) => {
const { pool, k } = contract const { pool, k } = contract
@ -32,7 +34,7 @@ export const getNewBinaryCpmmBetInfo = (
? [y - shares + amount, amount] ? [y - shares + amount, amount]
: [amount, n - shares + amount] : [amount, n - shares + amount]
const newBalance = user.balance - amount const newBalance = user.balance - (amount - loanAmount)
const probBefore = getCpmmProbability(pool) const probBefore = getCpmmProbability(pool)
const newPool = { YES: newY, NO: newN } const newPool = { YES: newY, NO: newN }
@ -45,6 +47,7 @@ export const getNewBinaryCpmmBetInfo = (
amount, amount,
shares, shares,
outcome, outcome,
loanAmount,
probBefore, probBefore,
probAfter, probAfter,
createdTime: Date.now(), createdTime: Date.now(),
@ -58,6 +61,7 @@ export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: FullContract<DPM, Binary>, contract: FullContract<DPM, Binary>,
loanAmount: number,
newBetId: string newBetId: string
) => { ) => {
const { YES: yesPool, NO: noPool } = contract.pool const { YES: yesPool, NO: noPool } = contract.pool
@ -91,6 +95,7 @@ export const getNewBinaryDpmBetInfo = (
userId: user.id, userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -98,7 +103,7 @@ export const getNewBinaryDpmBetInfo = (
createdTime: Date.now(), createdTime: Date.now(),
} }
const newBalance = user.balance - amount const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
} }
@ -108,6 +113,7 @@ export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FullContract<DPM, Multi | FreeResponse>, contract: FullContract<DPM, Multi | FreeResponse>,
loanAmount: number,
newBetId: string newBetId: string
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
@ -131,6 +137,7 @@ export const getNewMultiBetInfo = (
userId: user.id, userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -138,7 +145,17 @@ export const getNewMultiBetInfo = (
createdTime: Date.now(), createdTime: Date.now(),
} }
const newBalance = user.balance - amount const newBalance = user.balance - (amount - loanAmount)
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
} }
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -281,3 +281,12 @@ export const getPayoutsMultiOutcome = (
.map(({ userId, payout }) => ({ userId, payout })) .map(({ userId, payout }) => ({ userId, payout }))
.concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee
} }
export const getLoanPayouts = (bets: Bet[]) => {
const betsWithLoans = bets.filter((bet) => bet.loanAmount)
const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId)
const loansByUser = _.mapValues(betsByUser, (bets) =>
_.sumBy(bets, (bet) => -(bet.loanAmount ?? 0))
)
return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout }))
}

View File

@ -48,8 +48,9 @@ export function scoreUsersByContract(
const investments = bets const investments = bets
.filter((bet) => !bet.sale) .filter((bet) => !bet.sale)
.map((bet) => { .map((bet) => {
const { userId, amount } = bet const { userId, amount, loanAmount } = bet
return { userId, payout: -amount } const payout = -amount - (loanAmount ?? 0)
return { userId, payout }
}) })
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]

View File

@ -16,7 +16,7 @@ export const getSellBetInfo = (
newBetId: string newBetId: string
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateShareValue(contract, bet) const adjShareValue = calculateShareValue(contract, bet)
@ -62,7 +62,7 @@ export const getSellBetInfo = (
}, },
} }
const newBalance = user.balance + saleAmount const newBalance = user.balance + saleAmount - (loanAmount ?? 0)
return { return {
newBet, newBet,

View File

@ -28,6 +28,8 @@ export type PrivateUser = {
email?: string email?: string
unsubscribedFromResolutionEmails?: boolean unsubscribedFromResolutionEmails?: boolean
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
} }

View File

@ -5,6 +5,7 @@
"source": "functions" "source": "functions"
}, },
"firestore": { "firestore": {
"rules": "firestore.rules" "rules": "firestore.rules",
"indexes": "firestore.indexes.json"
} }
} }

296
firestore.indexes.json Normal file
View File

@ -0,0 +1,296 @@
{
"indexes": [
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "volume24Hours",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "volume24Hours",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "volume24Hours",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "volume24Hours",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "slug",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
}
],
"fieldOverrides": [
{
"collectionGroup": "answers",
"fieldPath": "isAnte",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "answers",
"fieldPath": "username",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "bets",
"fieldPath": "createdTime",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "bets",
"fieldPath": "userId",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "comments",
"fieldPath": "createdTime",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "comments",
"fieldPath": "userUsername",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "followers",
"fieldPath": "userId",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
}
]
}

View File

@ -1,3 +1,6 @@
# Secrets
.env*
# Compiled JavaScript files # Compiled JavaScript files
lib/**/*.js lib/**/*.js
lib/**/*.js.map lib/**/*.js.map

View File

@ -3,9 +3,11 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getNewMultiBetInfo } from '../../common/new-bet' import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getValues } from './utils' import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { Bet } from '../../common/bet'
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -28,7 +30,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'Invalid text' } return { status: 'error', message: 'Invalid text' }
// Run as transaction to prevent race conditions. // Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => { const result = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc) const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) if (!userSnap.exists)
@ -54,6 +56,11 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const [lastAnswer] = await getValues<Answer>( const [lastAnswer] = await getValues<Answer>(
firestore firestore
.collection(`contracts/${contractId}/answers`) .collection(`contracts/${contractId}/answers`)
@ -91,8 +98,17 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
.collection(`contracts/${contractId}/bets`) .collection(`contracts/${contractId}/bets`)
.doc() .doc()
const loanAmount = getLoanAmount(yourBets, amount)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) getNewMultiBetInfo(
user,
answerId,
amount,
loanAmount,
contract,
newBetDoc.id
)
transaction.create(newBetDoc, newBet) transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, { transaction.update(contractDoc, {
@ -103,8 +119,15 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
}) })
transaction.update(userDoc, { balance: newBalance }) transaction.update(userDoc, { balance: newBalance })
return { status: 'success', answerId, betId: newBetDoc.id } return { status: 'success', answerId, betId: newBetDoc.id, answer }
}) })
const { answer } = result
const contract = await getContract(contractId)
if (answer && contract) await sendNewAnswerEmail(answer, contract)
return result
} }
) )

View File

@ -14,6 +14,7 @@ import {
cleanUsername, cleanUsername,
} from '../../common/util/clean-username' } from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails' import { sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/access'
export const createUser = functions export const createUser = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
@ -32,6 +33,9 @@ export const createUser = functions
const fbUser = await admin.auth().getUser(userId) const fbUser = await admin.auth().getUser(userId)
const email = fbUser.email const email = fbUser.email
if (!isWhitelisted(email)) {
return { status: 'error', message: `${email} is not whitelisted` }
}
const emailName = email?.replace(/@.*$/, '') const emailName = email?.replace(/@.*$/, '')
const rawName = fbUser.displayName || emailName || 'User' + randomString(4) const rawName = fbUser.displayName || emailName || 'User' + randomString(4)

View File

@ -0,0 +1,561 @@
<!DOCTYPE html>
<html
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
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">
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;
}
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
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
"
valign="top"
>
Answer {{answerNumber}}
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding-top: 5px;
padding-bottom: 20px;
"
valign="top"
>
<div
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{answer}}</span
>
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div>
<img
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
<span style="font-weight: bold"
>{{commentorName}}</span
>
{{betDescription}}
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{comment}}</span
>
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 20px 0 0 0; margin: 0">
<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]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View comment</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,499 @@
<!DOCTYPE html>
<html
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
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">
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;
}
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
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
font-weight: bold;
"
valign="top"
>
<div>
<img
src="{{avatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
{{name}}
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{answer}}</span
>
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 20px 0 0 0; margin: 0">
<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]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View answer</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,647 @@
<!DOCTYPE html>
<html
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
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">
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;
}
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
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
You asked
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
font-size: 24px;
color: #000;
line-height: 1.2em;
font-weight: 500;
text-align: left;
margin: 0 0 0 0;
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
font-size: 24px;
color: #000;
line-height: 1.2em;
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
Market closed
</h2>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
Hi {{name}},
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
A market you created has closed. It's attracted
<span style="font-weight: bold">{{pool}}</span> in
bets — congrats!
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Resolve your market to earn {{creatorFee}}% of the
profits as the creator commission.
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Thanks,
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Manifold Team
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 10px 0 0 0; margin: 0">
<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]-->
<a
href="{{url}}"
target="_blank"
style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolve"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,501 @@
<!DOCTYPE html>
<html
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
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">
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;
}
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
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div>
<img
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
<span style="font-weight: bold"
>{{commentorName}}</span
>
{{betDescription}}
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{comment}}</span
>
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 20px 0 0 0; margin: 0">
<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]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View comment</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,669 @@
<!DOCTYPE html>
<html
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
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">
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;
}
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
itemscope
itemtype="http://schema.org/EmailMessage"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
{{creatorName}} asked
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
font-size: 24px;
color: #000;
line-height: 1.2em;
font-weight: 500;
text-align: left;
margin: 0 0 0 0;
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
font-size: 24px;
color: #000;
line-height: 1.2em;
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
Resolved {{outcome}}
</h2>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
Dear {{name}},
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
A market you bet in has been resolved!
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Your investment was
<span style="font-weight: bold"
>M$ {{investment}}</span
>.
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Your payout is
<span style="font-weight: bold"
>M$ {{payout}}</span
>.
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Thanks,
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Manifold Team
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 10px 0 0 0; margin: 0">
<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]-->
<a
href="{{url}}"
target="_blank"
style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolved"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>

View File

@ -1,11 +1,14 @@
import _ = require('lodash') import _ = require('lodash')
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { CREATOR_FEE } from '../../common/fees' import { CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format' import { formatMoney, formatPercent } from '../../common/util/format'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser, isProd } from './utils'
type market_resolved_template = { type market_resolved_template = {
userId: string userId: string
@ -13,13 +16,14 @@ type market_resolved_template = {
creatorName: string creatorName: string
question: string question: string
outcome: string outcome: string
investment: string
payout: string payout: string
url: string url: string
} }
const toDisplayResolution = ( const toDisplayResolution = (
outcome: string, outcome: string,
prob: number, prob?: number,
resolutions?: { [outcome: string]: number } resolutions?: { [outcome: string]: number }
) => { ) => {
if (outcome === 'MKT' && resolutions) return 'MULTI' if (outcome === 'MKT' && resolutions) return 'MULTI'
@ -28,7 +32,7 @@ const toDisplayResolution = (
YES: 'YES', YES: 'YES',
NO: 'NO', NO: 'NO',
CANCEL: 'N/A', CANCEL: 'N/A',
MKT: formatPercent(prob), MKT: formatPercent(prob ?? 0),
}[outcome] }[outcome]
return display === undefined ? `#${outcome}` : display return display === undefined ? `#${outcome}` : display
@ -36,6 +40,7 @@ const toDisplayResolution = (
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
userId: string, userId: string,
investment: number,
payout: number, payout: number,
creator: User, creator: User,
contract: Contract, contract: Contract,
@ -66,6 +71,7 @@ export const sendMarketResolutionEmail = async (
creatorName: creator.name, creatorName: creator.name,
question: contract.question, question: contract.question,
outcome, outcome,
investment: `${Math.round(investment)}`,
payout: `${Math.round(payout)}`, payout: `${Math.round(payout)}`,
url: `https://manifold.markets/${creator.username}/${contract.slug}`, url: `https://manifold.markets/${creator.username}/${contract.slug}`,
} }
@ -138,3 +144,119 @@ export const sendMarketCloseEmail = async (
} }
) )
} }
export const sendNewCommentEmail = async (
userId: string,
commentCreator: User,
contract: Contract,
comment: Comment,
bet: Bet,
answer?: Answer
) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromCommentEmails
)
return
const { question, creatorUsername, slug } = contract
const marketUrl = `https://manifold.markets/${creatorUsername}/${slug}`
const unsubscribeUrl = `https://us-central1-${
isProd ? 'mantic-markets' : 'dev-mantic-markets'
}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment
const { amount, sale, outcome } = bet
let betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = answer?.text ?? ''
const answerNumber = `#${answer?.id ?? ''}`
await sendTemplateEmail(
privateUser.email,
subject,
'market-answer-comment',
{
answer: answerText,
answerNumber,
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
marketUrl,
unsubscribeUrl,
betDescription,
},
{ from }
)
} else {
betDescription = `${betDescription} of ${toDisplayResolution(outcome)}`
await sendTemplateEmail(
privateUser.email,
subject,
'market-comment',
{
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
marketUrl,
unsubscribeUrl,
betDescription,
},
{ from }
)
}
}
export const sendNewAnswerEmail = async (
answer: Answer,
contract: Contract
) => {
// Send to just the creator for now.
const { creatorId: userId } = contract
// Don't send the creator's own answers.
if (answer.userId === userId) return
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromAnswerEmails
)
return
const { question, creatorUsername, slug } = contract
const { name, avatarUrl, text } = answer
const marketUrl = `https://manifold.markets/${creatorUsername}/${slug}`
const unsubscribeUrl = `https://us-central1-${
isProd ? 'mantic-markets' : 'dev-mantic-markets'
}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer`
const subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>`
await sendTemplateEmail(
privateUser.email,
subject,
'market-answer',
{
name,
avatarUrl: avatarUrl ?? '',
answer: text,
marketUrl,
unsubscribeUrl,
},
{ from }
)
}

View File

@ -12,6 +12,7 @@ export * from './create-contract'
export * from './create-user' export * from './create-user'
export * from './create-fold' export * from './create-fold'
export * from './create-answer' export * from './create-answer'
export * from './on-create-comment'
export * from './on-fold-follow' export * from './on-fold-follow'
export * from './on-fold-delete' export * from './on-fold-delete'
export * from './unsubscribe' export * from './unsubscribe'

View File

@ -0,0 +1,60 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
const firestore = admin.firestore()
export const onCreateComment = functions.firestore
.document('contracts/{contractId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const contract = await getContract(contractId)
if (!contract) return
const comment = change.data() as Comment
const commentCreator = await getUser(comment.userId)
if (!commentCreator) return
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.doc(comment.betId)
.get()
const bet = betSnapshot.data() as Bet
const answer =
contract.answers &&
contract.answers.find((answer) => answer.id === bet.outcome)
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contractId).collection('comments')
)
const recipientUserIds = _.uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNewCommentEmail(
userId,
commentCreator,
contract,
comment,
bet,
answer
)
)
)
})

View File

@ -7,8 +7,11 @@ import {
getNewBinaryCpmmBetInfo, getNewBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo, getNewBinaryDpmBetInfo,
getNewMultiBetInfo, getNewMultiBetInfo,
getLoanAmount,
} from '../../common/new-bet' } from '../../common/new-bet'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { Bet } from '../../common/bet'
import { getValues } from './utils'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -51,6 +54,11 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
if (outcomeType === 'FREE_RESPONSE') { if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get( const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome) contractDoc.collection('answers').doc(outcome)
@ -63,6 +71,8 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
.collection(`contracts/${contractId}/bets`) .collection(`contracts/${contractId}/bets`)
.doc() .doc()
const loanAmount = getLoanAmount(yourBets, amount)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? mechanism === 'dpm-2' ? mechanism === 'dpm-2'
@ -71,6 +81,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
amount, amount,
contract, contract,
loanAmount,
newBetDoc.id newBetDoc.id
) )
: (getNewBinaryCpmmBetInfo( : (getNewBinaryCpmmBetInfo(
@ -78,6 +89,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
amount, amount,
contract, contract,
loanAmount,
newBetDoc.id newBetDoc.id
) as any) ) as any)
: getNewMultiBetInfo( : getNewMultiBetInfo(
@ -85,6 +97,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
outcome, outcome,
amount, amount,
contract as any, contract as any,
loanAmount,
newBetDoc.id newBetDoc.id
) )

View File

@ -7,7 +7,12 @@ import { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, payUser } from './utils' import { getUser, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails' import { sendMarketResolutionEmail } from './emails'
import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts' import {
getLoanPayouts,
getPayouts,
getPayoutsMultiOutcome,
} from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object'
export const resolveMarket = functions export const resolveMarket = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
@ -31,7 +36,7 @@ export const resolveMarket = functions
if (!contractSnap.exists) if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' } return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { creatorId, outcomeType } = contract const { creatorId, outcomeType, closeTime } = contract
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
@ -68,15 +73,21 @@ export const resolveMarket = functions
const resolutionProbability = const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined probabilityInt !== undefined ? probabilityInt / 100 : undefined
await contractDoc.update({ const resolutionTime = Date.now()
const newCloseTime = closeTime
? Math.min(closeTime, resolutionTime)
: closeTime
await contractDoc.update(
removeUndefinedProps({
isResolved: true, isResolved: true,
resolution: outcome, resolution: outcome,
resolutionTime: Date.now(), resolutionTime,
...(resolutionProbability === undefined closeTime: newCloseTime,
? {} resolutionProbability,
: { resolutionProbability }), resolutions,
...(resolutions === undefined ? {} : { resolutions }),
}) })
)
console.log('contract ', contractId, 'resolved to:', outcome) console.log('contract ', contractId, 'resolved to:', outcome)
@ -92,13 +103,23 @@ export const resolveMarket = functions
? getPayoutsMultiOutcome(resolutions, contract as any, openBets) ? getPayoutsMultiOutcome(resolutions, contract as any, openBets)
: getPayouts(outcome, contract, openBets, resolutionProbability) : getPayouts(outcome, contract, openBets, resolutionProbability)
const loanPayouts = getLoanPayouts(openBets)
console.log('payouts:', payouts) console.log('payouts:', payouts)
const groups = _.groupBy(payouts, (payout) => payout.userId) const groups = _.groupBy(
[...payouts, ...loanPayouts],
(payout) => payout.userId
)
const userPayouts = _.mapValues(groups, (group) => const userPayouts = _.mapValues(groups, (group) =>
_.sumBy(group, (g) => g.payout) _.sumBy(group, (g) => g.payout)
) )
const groupsWithoutLoans = _.groupBy(payouts, (payout) => payout.userId)
const userPayoutsWithoutLoans = _.mapValues(groupsWithoutLoans, (group) =>
_.sumBy(group, (g) => g.payout)
)
const payoutPromises = Object.entries(userPayouts).map( const payoutPromises = Object.entries(userPayouts).map(
([userId, payout]) => payUser(userId, payout) ([userId, payout]) => payUser(userId, payout)
) )
@ -109,7 +130,7 @@ export const resolveMarket = functions
await sendResolutionEmails( await sendResolutionEmails(
openBets, openBets,
userPayouts, userPayoutsWithoutLoans,
creator, creator,
contract, contract,
outcome, outcome,
@ -134,14 +155,24 @@ const sendResolutionEmails = async (
_.uniq(openBets.map(({ userId }) => userId)), _.uniq(openBets.map(({ userId }) => userId)),
Object.keys(userPayouts) Object.keys(userPayouts)
) )
const investedByUser = _.mapValues(
_.groupBy(openBets, (bet) => bet.userId),
(bets) => _.sumBy(bets, (bet) => bet.amount)
)
const emailPayouts = [ const emailPayouts = [
...Object.entries(userPayouts), ...Object.entries(userPayouts),
...nonWinners.map((userId) => [userId, 0] as const), ...nonWinners.map((userId) => [userId, 0] as const),
] ].map(([userId, payout]) => ({
userId,
investment: investedByUser[userId],
payout,
}))
await Promise.all( await Promise.all(
emailPayouts.map(([userId, payout]) => emailPayouts.map(({ userId, investment, payout }) =>
sendMarketResolutionEmail( sendMarketResolutionEmail(
userId, userId,
investment,
payout, payout,
creator, creator,
contract, contract,

View File

@ -0,0 +1,44 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'
async function removeAnswerAnte() {
const firestore = admin.firestore()
console.log('Removing isAnte from bets on answers')
const contracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('outcomeType', '==', 'FREE_RESPONSE')
)
console.log('Loaded', contracts, 'contracts')
for (const contract of contracts) {
const betsSnapshot = await firestore
.collection('contracts')
.doc(contract.id)
.collection('bets')
.get()
console.log('updating', contract.question)
for (const doc of betsSnapshot.docs) {
const bet = doc.data() as Bet
if (bet.isAnte && bet.outcome !== '0') {
console.log('updating', bet.outcome)
await doc.ref.update('isAnte', false)
}
}
}
}
if (require.main === module) {
removeAnswerAnte().then(() => process.exit())
}

View File

@ -24,10 +24,11 @@ export const sendTemplateEmail = (
to: string, to: string,
subject: string, subject: string,
templateId: string, templateId: string,
templateData: Record<string, string> templateData: Record<string, string>,
options?: { from: string }
) => { ) => {
const data = { const data = {
from: 'Manifold Markets <info@manifold.markets>', from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
to, to,
subject, subject,
template: templateId, template: templateId,

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import Stripe from 'stripe' import Stripe from 'stripe'
import { payUser } from './utils' import { isProd, payUser } from './utils'
export type StripeTransaction = { export type StripeTransaction = {
userId: string userId: string
@ -18,8 +18,7 @@ const stripe = new Stripe(functions.config().stripe.apikey, {
}) })
// manage at https://dashboard.stripe.com/test/products?active=true // manage at https://dashboard.stripe.com/test/products?active=true
const manticDollarStripePrice = const manticDollarStripePrice = isProd
admin.instanceId().app.options.projectId === 'mantic-markets'
? { ? {
500: 'price_1KFQXcGdoFKoCJW770gTNBrm', 500: 'price_1KFQXcGdoFKoCJW770gTNBrm',
1000: 'price_1KFQp1GdoFKoCJW7Iu0dsF65', 1000: 'price_1KFQp1GdoFKoCJW7Iu0dsF65',

View File

@ -1,30 +1,47 @@
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 * as _ from 'lodash' import * as _ from 'lodash'
import { getPrivateUser } from './utils' import { getUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
export const unsubscribe = functions export const unsubscribe = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
.https.onRequest(async (req, res) => { .https.onRequest(async (req, res) => {
let id = req.query.id as string const { id, type } = req.query as { id: string; type: string }
if (!id) return if (!id || !type) return
let privateUser = await getPrivateUser(id) const user = await getUser(id)
if (privateUser) { if (user) {
let { username } = privateUser const { name } = user
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
...(type === 'market-resolve' && {
unsubscribedFromResolutionEmails: true, unsubscribedFromResolutionEmails: true,
}),
...(type === 'market-comment' && {
unsubscribedFromCommentEmails: true,
}),
...(type === 'market-answer' && {
unsubscribedFromAnswerEmails: true,
}),
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
if (type === 'market-resolve')
res.send( res.send(
username + `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
', you have been unsubscribed from market resolution emails on Manifold Markets.'
) )
else if (type === 'market-comment')
res.send(
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
)
else if (type === 'market-answer')
res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`)
} else { } else {
res.send('This user is not currently subscribed or does not exist.') res.send('This user is not currently subscribed or does not exist.')
} }

View File

@ -52,7 +52,8 @@ const computeInvestmentValue = async (
if (!contract || contract.isResolved) return 0 if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0 if (bet.sale || bet.isSold) return 0
return calculatePayout(contract, bet, 'MKT') const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0)
}) })
} }

View File

@ -3,6 +3,9 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
export const isProd =
admin.instanceId().app.options.projectId === 'mantic-markets'
export const getValue = async <T>(collection: string, doc: string) => { export const getValue = async <T>(collection: string, doc: string) => {
const snap = await admin.firestore().collection(collection).doc(doc).get() const snap = await admin.firestore().collection(collection).doc(doc).get()

View File

@ -1,15 +1,20 @@
import clsx from 'clsx' import clsx from 'clsx'
import _ from 'lodash'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { formatMoney } from '../../common/util/format' import { formatMoney } from '../../common/util/format'
import { AddFundsButton } from './add-funds-button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useUserContractBets } from '../hooks/use-user-bets'
import { MAX_LOAN_PER_CONTRACT } from '../../common/bet'
import { InfoTooltip } from './info-tooltip'
import { Spacer } from './layout/spacer'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
onChange: (newAmount: number | undefined) => void onChange: (newAmount: number | undefined) => void
error: string | undefined error: string | undefined
setError: (error: string | undefined) => void setError: (error: string | undefined) => void
contractId: string | undefined
minimumAmount?: number minimumAmount?: number
disabled?: boolean disabled?: boolean
className?: string className?: string
@ -22,6 +27,7 @@ export function AmountInput(props: {
onChange, onChange,
error, error,
setError, setError,
contractId,
disabled, disabled,
className, className,
inputClassName, inputClassName,
@ -31,10 +37,24 @@ export function AmountInput(props: {
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contractId) ?? []
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
amount ?? 0,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
const onAmountChange = (str: string) => { const onAmountChange = (str: string) => {
if (str.includes('-')) {
onChange(undefined)
return
}
const amount = parseInt(str.replace(/[^\d]/, '')) const amount = parseInt(str.replace(/[^\d]/, ''))
if (str && isNaN(amount)) return if (str && isNaN(amount)) return
if (amount >= 10 ** 9) return
onChange(str ? amount : undefined) onChange(str ? amount : undefined)
@ -47,7 +67,8 @@ export function AmountInput(props: {
} }
} }
const remainingBalance = Math.max(0, (user?.balance ?? 0) - (amount ?? 0)) const amountNetLoan = (amount ?? 0) - loanAmount
const remainingBalance = Math.max(0, (user?.balance ?? 0) - amountNetLoan)
return ( return (
<Col className={className}> <Col className={className}>
@ -68,19 +89,34 @@ export function AmountInput(props: {
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
/> />
</label> </label>
<Spacer h={4} />
{error && ( {error && (
<div className="mr-auto mt-4 self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error} {error}
</div> </div>
)} )}
{user && ( {user && (
<Col className="mt-3 text-sm"> <Col className="gap-3 text-sm">
<div className="mb-2 whitespace-nowrap text-gray-500"> {contractId && (
Remaining balance <Row className="items-center justify-between gap-2 text-gray-500">
</div> <Row className="items-center gap-2">
<Row className="gap-4"> Amount loaned{' '}
<div>{formatMoney(Math.floor(remainingBalance))}</div> <InfoTooltip
{user.balance !== 1000 && <AddFundsButton />} text={`In every market, you get an interest-free loan on the first ${formatMoney(
MAX_LOAN_PER_CONTRACT
)}.`}
/>
</Row>
<span className="text-neutral">{formatMoney(loanAmount)}</span>{' '}
</Row>
)}
<Row className="items-center justify-between gap-2 text-gray-500">
Remaining balance{' '}
<span className="text-neutral">
{formatMoney(Math.floor(remainingBalance))}
</span>
</Row> </Row>
</Col> </Col>
)} )}

View File

@ -30,8 +30,9 @@ export function AnswerBetPanel(props: {
answer: Answer answer: Answer
contract: Contract contract: Contract
closePanel: () => void closePanel: () => void
className?: string
}) { }) {
const { answer, contract, closePanel } = props const { answer, contract, closePanel, className } = props
const { id: answerId } = answer const { id: answerId } = answer
const user = useUser() const user = useUser()
@ -97,7 +98,7 @@ export function AnswerBetPanel(props: {
const currentReturnPercent = (currentReturn * 100).toFixed() + '%' const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
return ( return (
<Col className="items-start px-2 pb-2 pt-4 sm:pt-0"> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="self-stretch items-center justify-between"> <Row className="self-stretch items-center justify-between">
<div className="text-xl">Buy this answer</div> <div className="text-xl">Buy this answer</div>
@ -114,21 +115,21 @@ export function AnswerBetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
contractId={contract.id}
/> />
<Col className="gap-3 mt-3 w-full">
<Spacer h={4} /> <Row className="justify-between items-center text-sm">
<div className="text-gray-500">Probability</div>
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div>
<Row> <Row>
<div>{formatPercent(initialProb)}</div> <div>{formatPercent(initialProb)}</div>
<div className="mx-2"></div> <div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div> <div>{formatPercent(resultProb)}</div>
</Row> </Row>
</Row>
<Spacer h={4} /> <Row className="justify-between items-start text-sm gap-2">
<Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> <div>Payout if chosen</div>
Payout if chosen
<InfoTooltip <InfoTooltip
text={`Current payout for ${formatWithCommas( text={`Current payout for ${formatWithCommas(
shares shares
@ -137,17 +138,21 @@ export function AnswerBetPanel(props: {
)} shares`} )} shares`}
/> />
</Row> </Row>
<div> <Row className="flex-wrap justify-end items-end gap-2">
<span className="whitespace-nowrap">
{formatMoney(currentPayout)} {formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span> </span>
</div> <span>(+{currentReturnPercent})</span>
</Row>
</Row>
</Col>
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<button <button
className={clsx( className={clsx(
'btn', 'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary', betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : '' isSubmitting ? 'loading' : ''
)} )}
@ -157,7 +162,7 @@ export function AnswerBetPanel(props: {
</button> </button>
) : ( ) : (
<button <button
className="btn mt-4 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" className="btn self-stretch 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"
onClick={firebaseLogin} onClick={firebaseLogin}
> >
Sign in to trade! Sign in to trade!

View File

@ -8,13 +8,13 @@ import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { DateTimeTooltip } from '../datetime-tooltip'
import dayjs from 'dayjs'
import { BuyButton } from '../yes-no-selector' import { BuyButton } from '../yes-no-selector'
import { formatPercent } from '../../../common/util/format' import { formatPercent } from '../../../common/util/format'
import { getOutcomeProbability } from '../../../common/calculate' import { getOutcomeProbability } from '../../../common/calculate'
import { tradingAllowed } from '../../lib/firebase/contracts' import { tradingAllowed } from '../../lib/firebase/contracts'
import { AnswerBetPanel } from './answer-bet-panel' import { AnswerBetPanel } from './answer-bet-panel'
import { ContractFeed } from '../contract-feed'
import { Linkify } from '../linkify'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
@ -35,10 +35,9 @@ export function AnswerItem(props: {
onDeselect, onDeselect,
} = props } = props
const { resolution, resolutions, totalShares } = contract const { resolution, resolutions, totalShares } = contract
const { username, avatarUrl, name, createdTime, number, text } = answer const { username, avatarUrl, name, number, text } = answer
const isChosen = chosenProb !== undefined const isChosen = chosenProb !== undefined
const createdDate = dayjs(createdTime).format('MMM D')
const prob = getOutcomeProbability(totalShares, answer.id) const prob = getOutcomeProbability(totalShares, answer.id)
const roundedProb = Math.round(prob * 100) const roundedProb = Math.round(prob * 100)
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
@ -47,42 +46,50 @@ export function AnswerItem(props: {
const [isBetting, setIsBetting] = useState(false) const [isBetting, setIsBetting] = useState(false)
const canBet = !isBetting && !showChoice && tradingAllowed(contract)
return ( return (
<Col <div
className={clsx( className={clsx(
'p-4 sm:flex-row rounded gap-4', 'flex flex-col gap-4 rounded p-4 sm:flex-row',
wasResolvedTo wasResolvedTo
? resolution === 'MKT' ? resolution === 'MKT'
? 'bg-blue-50 mb-2' ? 'mb-2 bg-blue-50'
: 'bg-green-50 mb-8' : 'mb-8 bg-green-50'
: chosenProb === undefined : chosenProb === undefined
? 'bg-gray-50' ? 'bg-gray-50'
: showChoice === 'radio' : showChoice === 'radio'
? 'bg-green-50' ? 'bg-green-50'
: 'bg-blue-50' : 'bg-blue-50',
canBet && 'cursor-pointer hover:bg-gray-100'
)} )}
onClick={() => canBet && setIsBetting(true)}
> >
<Col className="gap-3 flex-1"> <Col className="flex-1 gap-3">
<div className="whitespace-pre-line break-words">{text}</div> <div className="whitespace-pre-line">
<Linkify text={text} />
</div>
<Row className="text-gray-500 text-sm gap-2 items-center"> <Row className="items-center gap-2 text-sm text-gray-500">
<SiteLink className="relative" href={`/${username}`}> <SiteLink className="relative" href={`/${username}`}>
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<Avatar avatarUrl={avatarUrl} size={6} /> <Avatar avatarUrl={avatarUrl} size={6} />
<div className="truncate">{name}</div> <div className="truncate">{name}</div>
</Row> </Row>
</SiteLink> </SiteLink>
{/* TODO: Show total pool? */}
<div className=""></div>
<div className="whitespace-nowrap">
<DateTimeTooltip text="" time={contract.createdTime}>
{createdDate}
</DateTimeTooltip>
</div>
<div className=""></div>
<div className="text-base">#{number}</div> <div className="text-base">#{number}</div>
</Row> </Row>
{isBetting && (
<ContractFeed
contract={contract}
bets={[]}
comments={[]}
feedType="multi"
outcome={answer.id}
/>
)}
</Col> </Col>
{isBetting ? ( {isBetting ? (
@ -90,13 +97,14 @@ export function AnswerItem(props: {
answer={answer} answer={answer}
contract={contract} contract={contract}
closePanel={() => setIsBetting(false)} closePanel={() => setIsBetting(false)}
className="sm:w-72"
/> />
) : ( ) : (
<Row className="self-end sm:self-start items-center gap-4 justify-end"> <Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo && {!wasResolvedTo &&
(showChoice === 'checkbox' ? ( (showChoice === 'checkbox' ? (
<input <input
className="input input-bordered text-2xl justify-self-end w-24" className="input input-bordered w-24 justify-self-end text-2xl"
type="number" type="number"
placeholder={`${roundedProb}`} placeholder={`${roundedProb}`}
maxLength={9} maxLength={9}
@ -121,7 +129,7 @@ export function AnswerItem(props: {
))} ))}
{showChoice ? ( {showChoice ? (
<div className="form-control py-1"> <div className="form-control py-1">
<label className="cursor-pointer label gap-3"> <label className="label cursor-pointer gap-3">
<span className="">Choose this answer</span> <span className="">Choose this answer</span>
{showChoice === 'radio' && ( {showChoice === 'radio' && (
<input <input
@ -162,7 +170,7 @@ export function AnswerItem(props: {
<> <>
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BuyButton <BuyButton
className="justify-end self-end flex-initial btn-md !px-8" className="btn-md flex-initial justify-end self-end !px-8"
onClick={() => { onClick={() => {
setIsBetting(true) setIsBetting(true)
}} }}
@ -188,6 +196,6 @@ export function AnswerItem(props: {
)} )}
</Row> </Row>
)} )}
</Col> </div>
) )
} }

View File

@ -86,7 +86,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) {
<div /> <div />
<Col <Col
className={clsx( className={clsx(
'sm:flex-row gap-4', 'sm:flex-row sm:items-end gap-4',
text ? 'justify-between' : 'self-end' text ? 'justify-between' : 'self-end'
)} )}
> >
@ -101,34 +101,42 @@ export function CreateAnswerPanel(props: { contract: Contract }) {
setError={setAmountError} setError={setAmountError}
minimumAmount={1} minimumAmount={1}
disabled={isSubmitting} disabled={isSubmitting}
contractId={contract.id}
/> />
</Col> </Col>
<Col className="gap-2 mt-1"> <Col className="gap-3">
<div className="text-sm text-gray-500">Implied probability</div> <Row className="justify-between items-center text-sm">
<div className="text-gray-500">Probability</div>
<Row> <Row>
<div>{formatPercent(0)}</div> <div>{formatPercent(0)}</div>
<div className="mx-2"></div> <div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div> <div>{formatPercent(resultProb)}</div>
</Row> </Row>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> </Row>
Payout if chosen
<Row className="justify-between text-sm gap-2">
<Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
<div>Payout if chosen</div>
<InfoTooltip <InfoTooltip
text={`Current payout for ${formatWithCommas( text={`Current payout for ${formatWithCommas(
shares shares
)} / ${formatWithCommas(shares)} shares`} )} / ${formatWithCommas(shares)} shares`}
/> />
</Row> </Row>
<div> <Row className="flex-wrap justify-end items-end gap-2">
<span className="whitespace-nowrap">
{formatMoney(currentPayout)} {formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span> </span>
</div> <span>(+{currentReturnPercent})</span>
</Row>
</Row>
</Col> </Col>
</> </>
)} )}
{user ? ( {user ? (
<button <button
className={clsx( className={clsx(
'btn self-end mt-2', 'btn mt-2',
canSubmit ? 'btn-outline' : 'btn-disabled', canSubmit ? 'btn-outline' : 'btn-disabled',
isSubmitting && 'loading' isSubmitting && 'loading'
)} )}

View File

@ -144,7 +144,6 @@ export function BetPanel(props: {
text={panelTitle} text={panelTitle}
/> />
{/* <div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> */}
<YesNoSelector <YesNoSelector
className="mb-4" className="mb-4"
selected={betChoice} selected={betChoice}
@ -160,45 +159,49 @@ export function BetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
contractId={contract.id}
/> />
<Spacer h={4} /> <Col className="gap-3 mt-3 w-full">
<Row className="justify-between items-center text-sm">
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div> <div className="text-gray-500">Probability</div>
<Row> <Row>
<div>{formatPercent(initialProb)}</div> <div>{formatPercent(initialProb)}</div>
<div className="mx-2"></div> <div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div> <div>{formatPercent(resultProb)}</div>
</Row> </Row>
</Row>
{betChoice && ( <Row className="justify-between items-start text-sm gap-2">
<> <Row className="flex-nowrap whitespace-nowrap items-center gap-2 text-gray-500">
<Spacer h={4} /> <div>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
Payout if <OutcomeLabel outcome={betChoice} /> </div>
<InfoTooltip <InfoTooltip
text={`Current payout for ${formatWithCommas( text={`Current payout for ${formatWithCommas(
shares shares
)} / ${formatWithCommas( )} / ${formatWithCommas(
shares + shares +
totalShares[betChoice] - totalShares[betChoice ?? 'YES'] -
(phantomShares ? phantomShares[betChoice] : 0) (phantomShares ? phantomShares[betChoice ?? 'YES'] : 0)
)} ${betChoice} shares`} )} ${betChoice} shares`}
/> />
</Row> </Row>
<div> <Row className="flex-wrap justify-end items-end gap-2">
<span className="whitespace-nowrap">
{formatMoney(currentPayout)} {formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span> </span>
</div> <span>(+{currentReturnPercent})</span>
</> </Row>
)} </Row>
</Col>
<Spacer h={6} /> <Spacer h={8} />
{user && ( {user && (
<button <button
className={clsx( className={clsx(
'btn', 'btn flex-1',
betDisabled betDisabled
? 'btn-disabled' ? 'btn-disabled'
: betChoice === 'YES' : betChoice === 'YES'
@ -213,7 +216,7 @@ export function BetPanel(props: {
)} )}
{user === null && ( {user === null && (
<button <button
className="btn mt-4 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" className="btn flex-1 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"
onClick={firebaseLogin} onClick={firebaseLogin}
> >
Sign in to trade! Sign in to trade!

View File

@ -37,7 +37,7 @@ import { filterDefined } from '../../common/util/array'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
type BetSort = 'newest' | 'profit' | 'resolved' | 'value' type BetSort = 'newest' | 'profit' | 'settled' | 'value'
export function BetsList(props: { user: User }) { export function BetsList(props: { user: User }) {
const { user } = props const { user } = props
@ -82,7 +82,8 @@ export function BetsList(props: { user: User }) {
if (bet.isSold || bet.sale) return 0 if (bet.isSold || bet.sale) return 0
const contract = contracts.find((c) => c.id === contractId) const contract = contracts.find((c) => c.id === contractId)
return contract ? calculatePayout(contract, bet, 'MKT') : 0 const payout = contract ? calculatePayout(contract, bet, 'MKT') : 0
return payout - (bet.loanAmount ?? 0)
}) })
} }
) )
@ -106,23 +107,20 @@ export function BetsList(props: { user: User }) {
contracts, contracts,
(c) => -1 * Math.max(...contractBets[c.id].map((bet) => bet.createdTime)) (c) => -1 * Math.max(...contractBets[c.id].map((bet) => bet.createdTime))
) )
else if (sort === 'resolved') else if (sort === 'settled')
sortedContracts = _.sortBy(contracts, (c) => -1 * (c.resolutionTime ?? 0)) sortedContracts = _.sortBy(contracts, (c) => -1 * (c.resolutionTime ?? 0))
const [resolved, unresolved] = _.partition( const [settled, unsettled] = _.partition(
sortedContracts, sortedContracts,
(c) => c.isResolved (c) => c.isResolved || contractsInvestment[c.id] === 0
) )
const displayedContracts = sort === 'resolved' ? resolved : unresolved const displayedContracts = sort === 'settled' ? settled : unsettled
const currentInvestment = _.sumBy( const currentInvestment = _.sumBy(unsettled, (c) => contractsInvestment[c.id])
unresolved,
(c) => contractsInvestment[c.id]
)
const currentBetsValue = _.sumBy( const currentBetsValue = _.sumBy(
unresolved, unsettled,
(c) => contractsCurrentValue[c.id] (c) => contractsCurrentValue[c.id]
) )
@ -140,7 +138,7 @@ export function BetsList(props: { user: User }) {
<Col> <Col>
<div className="text-sm text-gray-500">Invested</div> <div className="text-sm text-gray-500">Invested</div>
<div className="text-lg"> <div className="text-lg">
{formatMoney(currentBetsValue)}{' '} {formatMoney(currentInvestment)}{' '}
<ProfitBadge profitPercent={investedProfit} /> <ProfitBadge profitPercent={investedProfit} />
</div> </div>
</Col> </Col>
@ -160,8 +158,8 @@ export function BetsList(props: { user: User }) {
> >
<option value="value">By value</option> <option value="value">By value</option>
<option value="profit">By profit</option> <option value="profit">By profit</option>
<option value="newest">Newest</option> <option value="newest">Most recent</option>
<option value="resolved">Resolved</option> <option value="settled">Resolved</option>
</select> </select>
</Col> </Col>
@ -228,7 +226,7 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
/> />
</Row> </Row>
<Row className="items-center gap-2 text-sm text-gray-500 flex-1"> <Row className="flex-1 items-center gap-2 text-sm text-gray-500">
{isBinary && ( {isBinary && (
<> <>
{resolution ? ( {resolution ? (
@ -361,6 +359,8 @@ export function MyBetsSummary(props: {
{formatMoney(expectation)} {formatMoney(expectation)}
</div> </div>
</Col> */} </Col> */}
{isBinary && (
<>
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel /> Payout if <YesLabel />
@ -377,6 +377,8 @@ export function MyBetsSummary(props: {
{formatMoney(noWinnings)} {formatMoney(noWinnings)}
</div> </div>
</Col> </Col>
</>
)}
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
{isBinary ? ( {isBinary ? (
@ -421,9 +423,10 @@ export function ContractBetsTable(props: {
<thead> <thead>
<tr className="p-2"> <tr className="p-2">
<th></th> <th></th>
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
<th>Outcome</th> <th>Outcome</th>
<th>Amount</th> <th>Amount</th>
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
{!isResolved && <th>Payout if chosen</th>}
<th>Probability</th> <th>Probability</th>
<th>Shares</th> <th>Shares</th>
<th>Date</th> <th>Date</th>
@ -455,6 +458,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
shares, shares,
isSold, isSold,
isAnte, isAnte,
loanAmount,
} = bet } = bet
const { isResolved, closeTime } = contract const { isResolved, closeTime } = contract
@ -462,7 +466,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
const saleAmount = saleBet?.sale?.amount const saleAmount = saleBet?.sale?.amount
const saleDisplay = bet.isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
) : saleAmount !== undefined ? ( ) : saleAmount !== undefined ? (
<>{formatMoney(saleAmount)} (sold)</> <>{formatMoney(saleAmount)} (sold)</>
@ -474,6 +478,11 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
) )
) )
const payoutIfChosenDisplay =
bet.outcome === '0' && bet.isAnte
? 'N/A'
: formatMoney(calculatePayout(contract, bet, bet.outcome))
return ( return (
<tr> <tr>
<td className="text-neutral"> <td className="text-neutral">
@ -481,11 +490,15 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
<SellButton contract={contract} bet={bet} /> <SellButton contract={contract} bet={bet} />
)} )}
</td> </td>
<td>{saleDisplay}</td>
<td> <td>
<OutcomeLabel outcome={outcome} /> <OutcomeLabel outcome={outcome} />
</td> </td>
<td>{formatMoney(amount)}</td> <td>
{formatMoney(amount)}
{loanAmount ? ` (${formatMoney(loanAmount ?? 0)} loan)` : ''}
</td>
<td>{saleDisplay}</td>
{!isResolved && <td>{payoutIfChosenDisplay}</td>}
<td> <td>
{formatPercent(probBefore)} {formatPercent(probAfter)} {formatPercent(probBefore)} {formatPercent(probAfter)}
</td> </td>
@ -502,17 +515,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
}, []) }, [])
const { contract, bet } = props const { contract, bet } = props
const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const initialProb = getOutcomeProbability( const initialProb = getOutcomeProbability(
contract.totalShares, contract.totalShares,
bet.outcome === 'NO' ? 'YES' : bet.outcome outcome === 'NO' ? 'YES' : outcome
) )
const outcomeProb = getProbabilityAfterSale( const outcomeProb = getProbabilityAfterSale(
contract.totalShares, contract.totalShares,
bet.outcome, outcome,
bet.shares shares
) )
const saleAmount = calculateSaleAmount(contract, bet) const saleAmount = calculateSaleAmount(contract, bet)
@ -524,7 +539,7 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell', label: 'Sell',
}} }}
submitBtn={{ className: 'btn-primary' }} submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => { onSubmit={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id }) await sellBet({ contractId: contract.id, betId: bet.id })
@ -532,15 +547,18 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
}} }}
> >
<div className="mb-4 text-2xl"> <div className="mb-4 text-2xl">
Sell <OutcomeLabel outcome={bet.outcome} /> Sell {formatWithCommas(shares)} shares of{' '}
<OutcomeLabel outcome={outcome} /> for {formatMoney(saleAmount)}?
</div> </div>
<div> {!!loanAmount && (
Do you want to sell {formatWithCommas(bet.shares)} shares of{' '} <div className="mt-2">
<OutcomeLabel outcome={bet.outcome} /> for {formatMoney(saleAmount)}? You will also pay back {formatMoney(loanAmount)} of your loan, for a
net of {formatMoney(saleAmount - loanAmount)}.
</div> </div>
)}
<div className="mt-2 mb-1 text-sm text-gray-500"> <div className="mt-2 mb-1 text-sm">
Implied probability: {formatPercent(initialProb)} {' '} Market probability: {formatPercent(initialProb)} {' '}
{formatPercent(outcomeProb)} {formatPercent(outcomeProb)}
</div> </div>
</ConfirmationButton> </ConfirmationButton>

View File

@ -19,6 +19,7 @@ import { fromNow } from '../lib/util/time'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { useState } from 'react' import { useState } from 'react'
import { TweetButton } from './tweet-button'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -149,7 +150,8 @@ function AbbrContractDetails(props: {
) : showCloseTime ? ( ) : showCloseTime ? (
<Row className="gap-1"> <Row className="gap-1">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
Closes {fromNow(closeTime || 0)} {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row> </Row>
) : ( ) : (
<Row className="gap-1"> <Row className="gap-1">
@ -170,6 +172,8 @@ export function ContractDetails(props: {
const { closeTime, creatorName, creatorUsername } = contract const { closeTime, creatorName, creatorUsername } = contract
const { truePool, createdDate, resolvedDate } = contractMetrics(contract) const { truePool, createdDate, resolvedDate } = contractMetrics(contract)
const tweetText = getTweetText(contract, !!isCreator)
return ( return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap"> <Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
<Row className="flex-wrap items-center gap-x-4 gap-y-2"> <Row className="flex-wrap items-center gap-x-4 gap-y-2">
@ -222,6 +226,8 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
</Row> </Row>
<TweetButton className={'self-end'} tweetText={tweetText} />
</Row> </Row>
</Col> </Col>
) )
@ -307,9 +313,28 @@ function EditableCloseDate(props: {
className="btn btn-xs btn-ghost" className="btn btn-xs btn-ghost"
onClick={() => setIsEditingCloseTime(true)} onClick={() => setIsEditingCloseTime(true)}
> >
<PencilIcon className="inline h-4 w-4 mr-2" /> Edit <PencilIcon className="mr-2 inline h-4 w-4" /> Edit
</button> </button>
))} ))}
</> </>
) )
} }
const getTweetText = (contract: Contract, isCreator: boolean) => {
const { question, creatorName, resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const tweetQuestion = isCreator
? question
: `${question} Asked by ${creatorName}.`
const tweetDescription = resolution
? `Resolved ${resolution}!`
: isBinary
? `Currently ${getBinaryProbPercent(
contract
)} chance, place your bets here:`
: `Submit your own answer:`
const url = `https://manifold.markets${contractPath(contract)}`
return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
}

View File

@ -46,7 +46,7 @@ import { useAdmin } from '../hooks/use-admin'
function FeedComment(props: { function FeedComment(props: {
activityItem: any activityItem: any
moreHref: string moreHref: string
feedType: 'activity' | 'market' feedType: FeedType
}) { }) {
const { activityItem, moreHref, feedType } = props const { activityItem, moreHref, feedType } = props
const { person, text, amount, outcome, createdTime } = activityItem const { person, text, amount, outcome, createdTime } = activityItem
@ -65,7 +65,8 @@ function FeedComment(props: {
username={person.username} username={person.username}
name={person.name} name={person.name}
/>{' '} />{' '}
{bought} {money} of <OutcomeLabel outcome={outcome} />{' '} {bought} {money}
<MaybeOutcomeLabel outcome={outcome} feedType={feedType} />
<Timestamp time={createdTime} /> <Timestamp time={createdTime} />
</p> </p>
</div> </div>
@ -90,8 +91,8 @@ function Timestamp(props: { time: number }) {
) )
} }
function FeedBet(props: { activityItem: any }) { function FeedBet(props: { activityItem: any; feedType: FeedType }) {
const { activityItem } = props const { activityItem, feedType } = props
const { id, contractId, amount, outcome, createdTime } = activityItem const { id, contractId, amount, outcome, createdTime } = activityItem
const user = useUser() const user = useUser()
const isSelf = user?.id == activityItem.userId const isSelf = user?.id == activityItem.userId
@ -122,8 +123,9 @@ function FeedBet(props: { activityItem: any }) {
</div> </div>
<div className="min-w-0 flex-1 py-1.5"> <div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : 'A trader'}</span> {bought} {money} of{' '} <span>{isSelf ? 'You' : 'A trader'}</span> {bought} {money}
<OutcomeLabel outcome={outcome} /> <Timestamp time={createdTime} /> <MaybeOutcomeLabel outcome={outcome} feedType={feedType} />
<Timestamp time={createdTime} />
{canComment && ( {canComment && (
// Allow user to comment in an textarea if they are the creator // Allow user to comment in an textarea if they are the creator
<div className="mt-2"> <div className="mt-2">
@ -134,7 +136,7 @@ function FeedBet(props: { activityItem: any }) {
placeholder="Add a comment..." placeholder="Add a comment..."
rows={3} rows={3}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
submitComment() submitComment()
} }
}} }}
@ -179,7 +181,7 @@ function EditContract(props: {
e.target.setSelectionRange(text.length, text.length) e.target.setSelectionRange(text.length, text.length)
} }
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
onSave(text) onSave(text)
} }
}} }}
@ -305,15 +307,13 @@ function FeedQuestion(props: { contract: Contract }) {
const { truePool } = contractMetrics(contract) const { truePool } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
// Currently hidden on mobile; ideally we'd fit this in somewhere.
const closeMessage = const closeMessage =
contract.isResolved || !contract.closeTime ? null : ( contract.isResolved || !contract.closeTime ? null : (
<span className="float-right hidden text-gray-400 sm:inline"> <>
{formatMoney(truePool)} pool
<span className="mx-2"></span> <span className="mx-2"></span>
{contract.closeTime > Date.now() ? 'Closes' : 'Closed'} {contract.closeTime > Date.now() ? 'Closes' : 'Closed'}
<Timestamp time={contract.closeTime || 0} /> <Timestamp time={contract.closeTime || 0} />
</span> </>
) )
return ( return (
@ -330,7 +330,11 @@ function FeedQuestion(props: { contract: Contract }) {
username={creatorUsername} username={creatorUsername}
/>{' '} />{' '}
asked asked
{/* Currently hidden on mobile; ideally we'd fit this in somewhere. */}
<span className="float-right hidden text-gray-400 sm:inline">
{formatMoney(truePool)} pool
{closeMessage} {closeMessage}
</span>
</div> </div>
<Col className="mb-4 items-start justify-between gap-2 sm:flex-row sm:gap-4"> <Col className="mb-4 items-start justify-between gap-2 sm:flex-row sm:gap-4">
<SiteLink <SiteLink
@ -380,6 +384,29 @@ function FeedDescription(props: { contract: Contract }) {
) )
} }
function FeedAnswer(props: { contract: Contract; outcome: string }) {
const { contract, outcome } = props
const answer = contract?.answers?.[Number(outcome) - 1]
if (!answer) return null
return (
<>
<Avatar username={answer.username} avatarUrl={answer.avatarUrl} />
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<UserLink
className="text-gray-900"
name={answer.name}
username={answer.username}
/>{' '}
submitted answer <OutcomeLabel outcome={outcome} />{' '}
<Timestamp time={contract.createdTime} />
</div>
</div>
</>
)
}
function OutcomeIcon(props: { outcome?: string }) { function OutcomeIcon(props: { outcome?: string }) {
const { outcome } = props const { outcome } = props
switch (outcome) { switch (outcome) {
@ -538,8 +565,12 @@ function groupBets(
return items as ActivityItem[] return items as ActivityItem[]
} }
function BetGroupSpan(props: { bets: Bet[]; outcome: string }) { function BetGroupSpan(props: {
const { bets, outcome } = props bets: Bet[]
outcome: string
feedType: FeedType
}) {
const { bets, outcome, feedType } = props
const numberTraders = _.uniqBy(bets, (b) => b.userId).length const numberTraders = _.uniqBy(bets, (b) => b.userId).length
@ -554,14 +585,14 @@ function BetGroupSpan(props: { bets: Bet[]; outcome: string }) {
{buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>} {buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>}
{sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>} {sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>}
</JoinSpans> </JoinSpans>
of <OutcomeLabel outcome={outcome} /> <MaybeOutcomeLabel outcome={outcome} feedType={feedType} />{' '}
</span> </span>
) )
} }
// TODO: Make this expandable to show all grouped bets? // TODO: Make this expandable to show all grouped bets?
function FeedBetGroup(props: { activityItem: any }) { function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) {
const { activityItem } = props const { activityItem, feedType } = props
const bets: Bet[] = activityItem.bets const bets: Bet[] = activityItem.bets
const betGroups = _.groupBy(bets, (bet) => bet.outcome) const betGroups = _.groupBy(bets, (bet) => bet.outcome)
@ -583,7 +614,11 @@ function FeedBetGroup(props: { activityItem: any }) {
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{outcomes.map((outcome, index) => ( {outcomes.map((outcome, index) => (
<Fragment key={outcome}> <Fragment key={outcome}>
<BetGroupSpan outcome={outcome} bets={betGroups[outcome]} /> <BetGroupSpan
outcome={outcome}
bets={betGroups[outcome]}
feedType={feedType}
/>
{index !== outcomes.length - 1 && <br />} {index !== outcomes.length - 1 && <br />}
</Fragment> </Fragment>
))} ))}
@ -621,6 +656,18 @@ function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) {
) )
} }
// On 'multi' feeds, the outcome is redundant, so we hide it
function MaybeOutcomeLabel(props: { outcome: string; feedType: FeedType }) {
const { outcome, feedType } = props
return feedType === 'multi' ? null : (
<span>
{' '}
of <OutcomeLabel outcome={outcome} />
{/* TODO: Link to the correct e.g. #23 */}
</span>
)
}
// Missing feed items: // Missing feed items:
// - Bet sold? // - Bet sold?
type ActivityItem = { type ActivityItem = {
@ -635,15 +682,23 @@ type ActivityItem = {
| 'expand' | 'expand'
} }
type FeedType =
// Main homepage/fold feed,
| 'activity'
// Comments feed on a market
| 'market'
// Grouped for a multi-category outcome
| 'multi'
export function ContractFeed(props: { export function ContractFeed(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
// Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market feedType: FeedType
feedType: 'activity' | 'market' outcome?: string // Which multi-category outcome to filter
betRowClassName?: string betRowClassName?: string
}) { }) {
const { contract, feedType, betRowClassName } = props const { contract, feedType, outcome, betRowClassName } = props
const { id, outcomeType } = contract const { id, outcomeType } = contract
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
@ -655,6 +710,10 @@ export function ContractFeed(props: {
? bets.filter((bet) => !bet.isAnte) ? bets.filter((bet) => !bet.isAnte)
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
if (feedType === 'multi') {
bets = bets.filter((bet) => bet.outcome === outcome)
}
const comments = useComments(id) ?? props.comments const comments = useComments(id) ?? props.comments
const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS
@ -669,6 +728,10 @@ export function ContractFeed(props: {
if (contract.resolution) { if (contract.resolution) {
allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` })
} }
if (feedType === 'multi') {
// Hack to add some more padding above the 'multi' feedType, by adding a null item
allItems.unshift({ type: '', id: -1 })
}
// If there are more than 5 items, only show the first, an expand item, and last 3 // If there are more than 5 items, only show the first, an expand item, and last 3
let items = allItems let items = allItems
@ -682,10 +745,9 @@ export function ContractFeed(props: {
return ( return (
<div className="flow-root pr-2 md:pr-0"> <div className="flow-root pr-2 md:pr-0">
<ul role="list" className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}> <div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
{items.map((activityItem, activityItemIdx) => ( {items.map((activityItem, activityItemIdx) => (
<li key={activityItem.id}> <div key={activityItem.id} className="relative pb-8">
<div className="relative pb-8">
{activityItemIdx !== items.length - 1 ? ( {activityItemIdx !== items.length - 1 ? (
<span <span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
@ -694,11 +756,13 @@ export function ContractFeed(props: {
) : null} ) : null}
<div className="relative flex items-start space-x-3"> <div className="relative flex items-start space-x-3">
{activityItem.type === 'start' ? ( {activityItem.type === 'start' ? (
feedType == 'activity' ? ( feedType === 'activity' ? (
<FeedQuestion contract={contract} /> <FeedQuestion contract={contract} />
) : ( ) : feedType === 'market' ? (
<FeedDescription contract={contract} /> <FeedDescription contract={contract} />
) ) : feedType === 'multi' ? (
<FeedAnswer contract={contract} outcome={outcome || '0'} />
) : null
) : activityItem.type === 'comment' ? ( ) : activityItem.type === 'comment' ? (
<FeedComment <FeedComment
activityItem={activityItem} activityItem={activityItem}
@ -706,9 +770,9 @@ export function ContractFeed(props: {
feedType={feedType} feedType={feedType}
/> />
) : activityItem.type === 'bet' ? ( ) : activityItem.type === 'bet' ? (
<FeedBet activityItem={activityItem} /> <FeedBet activityItem={activityItem} feedType={feedType} />
) : activityItem.type === 'betgroup' ? ( ) : activityItem.type === 'betgroup' ? (
<FeedBetGroup activityItem={activityItem} /> <FeedBetGroup activityItem={activityItem} feedType={feedType} />
) : activityItem.type === 'close' ? ( ) : activityItem.type === 'close' ? (
<FeedClose contract={contract} /> <FeedClose contract={contract} />
) : activityItem.type === 'resolve' ? ( ) : activityItem.type === 'resolve' ? (
@ -718,9 +782,32 @@ export function ContractFeed(props: {
) : null} ) : null}
</div> </div>
</div> </div>
</li>
))} ))}
</ul> </div>
{isBinary && tradingAllowed(contract) && (
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
)}
</div>
)
}
export function ContractSummaryFeed(props: {
contract: Contract
betRowClassName?: string
}) {
const { contract, betRowClassName } = props
const { outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
return (
<div className="flow-root pr-2 md:pr-0">
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
<div className="relative pb-8">
<div className="relative flex items-start space-x-3">
<FeedQuestion contract={contract} />
</div>
</div>
</div>
{isBinary && tradingAllowed(contract) && ( {isBinary && tradingAllowed(contract) && (
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} /> <BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
)} )}

View File

@ -1,9 +1,7 @@
import { import {
Contract, Contract,
deleteContract, deleteContract,
contractPath,
tradingAllowed, tradingAllowed,
getBinaryProbPercent,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -15,7 +13,6 @@ import { Linkify } from './linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { ContractDetails, ResolutionOrChance } from './contract-card' import { ContractDetails, ResolutionOrChance } from './contract-card'
import { ContractFeed } from './contract-feed' import { ContractFeed } from './contract-feed'
import { TweetButton } from './tweet-button'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { RevealableTagsInput, TagsInput } from './tags-input' import { RevealableTagsInput, TagsInput } from './tags-input'
@ -38,8 +35,6 @@ export const ContractOverview = (props: {
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const tweetText = getTweetText(contract, isCreator)
return ( return (
<Col className={clsx('mb-6', className)}> <Col className={clsx('mb-6', className)}>
<Row className="justify-between gap-4 px-2"> <Row className="justify-between gap-4 px-2">
@ -92,11 +87,9 @@ export const ContractOverview = (props: {
) : ( ) : (
<FoldTagList folds={folds} /> <FoldTagList folds={folds} />
)} )}
<TweetButton tweetText={tweetText} />
</Row> </Row>
<Col className="mt-6 gap-4 sm:hidden"> <Col className="mt-6 gap-4 sm:hidden">
<TweetButton className="self-end" tweetText={tweetText} />
{folds.length === 0 ? ( {folds.length === 0 ? (
<TagsInput contract={contract} /> <TagsInput contract={contract} />
) : ( ) : (
@ -136,22 +129,3 @@ export const ContractOverview = (props: {
</Col> </Col>
) )
} }
const getTweetText = (contract: Contract, isCreator: boolean) => {
const { question, creatorName, resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const tweetQuestion = isCreator
? question
: `${question} Asked by ${creatorName}.`
const tweetDescription = resolution
? `Resolved ${resolution}!`
: isBinary
? `Currently ${getBinaryProbPercent(
contract
)} chance, place your bets here:`
: `Submit your own answer:`
const url = `https://manifold.markets${contractPath(contract)}`
return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
}

View File

@ -83,7 +83,7 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) {
format: (time) => formatTime(+time, lessThanAWeek), format: (time) => formatTime(+time, lessThanAWeek),
}} }}
colors={{ datum: 'color' }} colors={{ datum: 'color' }}
pointSize={10} pointSize={bets.length > 100 ? 0 : 10}
pointBorderWidth={1} pointBorderWidth={1}
pointBorderColor="#fff" pointBorderColor="#fff"
enableSlices="x" enableSlices="x"

View File

@ -205,16 +205,19 @@ export function SearchableGrid(props: {
}) { }) {
const { contracts, query, setQuery, sort, setSort, byOneCreator } = props const { contracts, query, setQuery, sort, setSort, byOneCreator } = props
const queryWords = query.toLowerCase().split(' ')
function check(corpus: String) { function check(corpus: String) {
return corpus.toLowerCase().includes(query.toLowerCase()) return queryWords.every((word) => corpus.toLowerCase().includes(word))
} }
let matches = contracts.filter( let matches = contracts.filter(
(c) => (c) =>
check(c.question) || check(c.question) ||
check(c.description) || check(c.description) ||
check(c.creatorName) || check(c.creatorName) ||
check(c.creatorUsername) || check(c.creatorUsername) ||
check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) ||
check((c.answers ?? []).map((answer) => answer.text).join(' '))
) )
if (sort === 'newest' || sort === 'all') { if (sort === 'newest' || sort === 'all') {
@ -226,11 +229,13 @@ export function SearchableGrid(props: {
) )
} else if (sort === 'oldest') { } else if (sort === 'oldest') {
matches.sort((a, b) => a.createdTime - b.createdTime) matches.sort((a, b) => a.createdTime - b.createdTime)
} else if (sort === 'close-date') { } else if (sort === 'close-date' || sort === 'closed') {
matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
matches = _.sortBy(matches, (contract) => contract.closeTime) matches = _.sortBy(matches, (contract) => contract.closeTime)
// Hide contracts that have already closed const hideClosed = sort === 'closed'
matches = matches.filter(({ closeTime }) => (closeTime || 0) > Date.now()) matches = matches.filter(
({ closeTime }) => closeTime && closeTime > Date.now() !== hideClosed
)
} else if (sort === 'most-traded') { } else if (sort === 'most-traded') {
matches.sort( matches.sort(
(a, b) => contractMetrics(b).truePool - contractMetrics(a).truePool (a, b) => contractMetrics(b).truePool - contractMetrics(a).truePool
@ -269,6 +274,7 @@ export function SearchableGrid(props: {
<option value="most-traded">Most traded</option> <option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option> <option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option> <option value="close-date">Closing soon</option>
<option value="closed">Closed</option>
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="oldest">Oldest</option> <option value="oldest">Oldest</option>
@ -286,7 +292,7 @@ export function SearchableGrid(props: {
) : ( ) : (
<ContractsGrid <ContractsGrid
contracts={matches} contracts={matches}
showCloseTime={sort == 'close-date'} showCloseTime={['close-date', 'closed'].includes(sort)}
/> />
)} )}
</div> </div>

View File

@ -101,7 +101,7 @@ export const FastFoldFollowing = (props: {
]} ]}
/> />
<Spacer h={10} /> <Spacer h={5} />
</> </>
) )
} }

View File

@ -34,10 +34,6 @@ function getNavigationOptions(
name: 'Home', name: 'Home',
href: user ? '/home' : '/', href: user ? '/home' : '/',
}, },
{
name: `Your profile`,
href: `/${user?.username}`,
},
...(mobile ...(mobile
? [ ? [
{ {
@ -50,10 +46,18 @@ function getNavigationOptions(
}, },
] ]
: []), : []),
{
name: `Your profile`,
href: `/${user?.username}`,
},
{ {
name: 'Your trades', name: 'Your trades',
href: '/trades', href: '/trades',
}, },
{
name: 'Add funds',
href: '/add-funds',
},
{ {
name: 'Leaderboards', name: 'Leaderboards',
href: '/leaderboards', href: '/leaderboards',

View File

@ -1,12 +1,8 @@
import { useUser } from './use-user' import { isAdmin } from '../../common/access'
import { usePrivateUser, useUser } from './use-user'
export const useAdmin = () => { export const useAdmin = () => {
const user = useUser() const user = useUser()
const adminIds = [ const privateUser = usePrivateUser(user?.id)
'igi2zGXsfxYPgB0DJTXVJVmwCOr2', // Austin return isAdmin(privateUser?.email || '')
'5LZ4LgYuySdL1huCWe7bti02ghx2', // James
'tlmGNz9kjXc2EteizMORes4qvWl2', // Stephen
'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', // Manifold
]
return adminIds.includes(user?.id || '')
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import {
Bet, Bet,
getRecentBets,
listenForBets, listenForBets,
listenForRecentBets, listenForRecentBets,
withoutAnteBets, withoutAnteBets,
@ -36,3 +37,11 @@ export const useRecentBets = () => {
useEffect(() => listenForRecentBets(setRecentBets), []) useEffect(() => listenForRecentBets(setRecentBets), [])
return recentBets return recentBets
} }
export const useGetRecentBets = () => {
const [recentBets, setRecentBets] = useState<Bet[] | undefined>()
useEffect(() => {
getRecentBets().then(setRecentBets)
}, [])
return recentBets
}

View File

@ -2,8 +2,10 @@ import _ from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Contract, Contract,
listenForActiveContracts,
listenForContracts, listenForContracts,
listenForHotContracts, listenForHotContracts,
listenForInactiveContracts,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { listenForTaggedContracts } from '../lib/firebase/folds' import { listenForTaggedContracts } from '../lib/firebase/folds'
@ -17,6 +19,26 @@ export const useContracts = () => {
return contracts return contracts
} }
export const useActiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
useEffect(() => {
return listenForActiveContracts(setContracts)
}, [])
return contracts
}
export const useInactiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
useEffect(() => {
return listenForInactiveContracts(setContracts)
}, [])
return contracts
}
export const useUpdatedContracts = (initialContracts: Contract[]) => { export const useUpdatedContracts = (initialContracts: Contract[]) => {
const [contracts, setContracts] = useState(initialContracts) const [contracts, setContracts] = useState(initialContracts)

View File

@ -1,24 +1,22 @@
import _ from 'lodash' import _ from 'lodash'
import { useRef } from 'react' import { useMemo, useRef } from 'react'
import { Fold } from '../../common/fold' import { Fold } from '../../common/fold'
import { User } from '../../common/user' import { User } from '../../common/user'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Bet, getRecentBets } from '../lib/firebase/bets' import { Bet, getRecentBets } from '../lib/firebase/bets'
import { Comment, getRecentComments } from '../lib/firebase/comments' import { Comment, getRecentComments } from '../lib/firebase/comments'
import { Contract, listAllContracts } from '../lib/firebase/contracts' import { Contract, getActiveContracts } from '../lib/firebase/contracts'
import { listAllFolds } from '../lib/firebase/folds' import { listAllFolds } from '../lib/firebase/folds'
import { findActiveContracts } from '../pages/activity' import { findActiveContracts } from '../pages/activity'
import { useRecentBets } from './use-bets' import { useInactiveContracts } from './use-contracts'
import { useRecentComments } from './use-comments'
import { useContracts } from './use-contracts'
import { useFollowedFolds } from './use-fold' import { useFollowedFolds } from './use-fold'
import { useUserBetContracts } from './use-user-bets' import { useUserBetContracts } from './use-user-bets'
// used in static props // used in static props
export const getAllContractInfo = async () => { export const getAllContractInfo = async () => {
let [contracts, folds] = await Promise.all([ let [contracts, folds] = await Promise.all([
listAllContracts().catch((_) => []), getActiveContracts().catch((_) => []),
listAllFolds().catch(() => []), listAllFolds().catch(() => []),
]) ])
@ -30,25 +28,15 @@ export const getAllContractInfo = async () => {
return { contracts, recentBets, recentComments, folds } return { contracts, recentBets, recentComments, folds }
} }
export const useActiveContracts = ( export const useFilterYourContracts = (
props: { user: User | undefined | null,
folds: Fold[],
contracts: Contract[] contracts: Contract[]
folds: Fold[]
recentBets: Bet[]
recentComments: Comment[]
},
user: User | undefined | null
) => { ) => {
const contracts = useContracts() ?? props.contracts
const recentBets = useRecentBets() ?? props.recentBets
const recentComments = useRecentComments() ?? props.recentComments
const followedFoldIds = useFollowedFolds(user) const followedFoldIds = useFollowedFolds(user)
const followedFolds = filterDefined( const followedFolds = filterDefined(
(followedFoldIds ?? []).map((id) => (followedFoldIds ?? []).map((id) => folds.find((fold) => fold.id === id))
props.folds.find((fold) => fold.id === id)
)
) )
// Save the initial followed fold slugs. // Save the initial followed fold slugs.
@ -67,23 +55,35 @@ export const useActiveContracts = (
: undefined : undefined
// Show no contracts before your info is loaded. // Show no contracts before your info is loaded.
let feedContracts: Contract[] = [] let yourContracts: Contract[] = []
if (yourBetContracts && followedFoldIds) { if (yourBetContracts && followedFoldIds) {
// Show all contracts if no folds are followed. // Show all contracts if no folds are followed.
if (followedFoldIds.length === 0) feedContracts = contracts if (followedFoldIds.length === 0) yourContracts = contracts
else else
feedContracts = contracts.filter( yourContracts = contracts.filter(
(contract) => (contract) =>
contract.lowercaseTags.some((tag) => tagSet.has(tag)) || contract.lowercaseTags.some((tag) => tagSet.has(tag)) ||
yourBetContracts.has(contract.id) yourBetContracts.has(contract.id)
) )
} }
return {
yourContracts,
initialFollowedFoldSlugs,
}
}
export const useFindActiveContracts = (props: {
contracts: Contract[]
recentBets: Bet[]
recentComments: Comment[]
}) => {
const { contracts, recentBets, recentComments } = props
const activeContracts = findActiveContracts( const activeContracts = findActiveContracts(
feedContracts, contracts,
recentComments, recentComments,
recentBets, recentBets
365
) )
const betsByContract = _.groupBy(recentBets, (bet) => bet.contractId) const betsByContract = _.groupBy(recentBets, (bet) => bet.contractId)
@ -105,6 +105,24 @@ export const useActiveContracts = (
activeContracts, activeContracts,
activeBets, activeBets,
activeComments, activeComments,
initialFollowedFoldSlugs,
} }
} }
export const useExploreContracts = (maxContracts = 75) => {
const inactiveContracts = useInactiveContracts()
const contractsDict = _.fromPairs(
(inactiveContracts ?? []).map((c) => [c.id, c])
)
// Preserve random ordering once inactiveContracts loaded.
const exploreContractIds = useMemo(
() => _.shuffle(Object.keys(contractsDict)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[!!inactiveContracts]
).slice(0, maxContracts)
if (!inactiveContracts) return undefined
return filterDefined(exploreContractIds.map((id) => contractsDict[id]))
}

View File

@ -1,4 +1,6 @@
import _ from 'lodash'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
export type Sort = export type Sort =
| 'creator' | 'creator'
@ -8,6 +10,7 @@ export type Sort =
| 'most-traded' | 'most-traded'
| '24-hour-vol' | '24-hour-vol'
| 'close-date' | 'close-date'
| 'closed'
| 'resolved' | 'resolved'
| 'all' | 'all'
@ -24,19 +27,34 @@ export function useQueryAndSortParams(options?: { defaultSort: Sort }) {
router.push(router, undefined, { shallow: true }) router.push(router, undefined, { shallow: true })
} }
const setQuery = (query: string | undefined) => { const [queryState, setQueryState] = useState(query)
useEffect(() => {
setQueryState(query)
}, [query])
// Debounce router query update.
const pushQuery = useMemo(
() =>
_.debounce((query: string | undefined) => {
if (query) { if (query) {
router.query.q = query router.query.q = query
} else { } else {
delete router.query.q delete router.query.q
} }
router.push(router, undefined, { shallow: true }) router.push(router, undefined, { shallow: true })
}, 500),
[router]
)
const setQuery = (query: string | undefined) => {
setQueryState(query)
pushQuery(query)
} }
return { return {
sort: sort ?? options?.defaultSort ?? '24-hour-vol', sort: sort ?? options?.defaultSort ?? '24-hour-vol',
query: query ?? '', query: queryState ?? '',
setSort, setSort,
setQuery, setQuery,
} }

View File

@ -1,6 +1,10 @@
import _ from 'lodash' import _ from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Bet, listenForUserBets } from '../lib/firebase/bets' import {
Bet,
listenForUserBets,
listenForUserContractBets,
} from '../lib/firebase/bets'
export const useUserBets = (userId: string | undefined) => { export const useUserBets = (userId: string | undefined) => {
const [bets, setBets] = useState<Bet[] | undefined>(undefined) const [bets, setBets] = useState<Bet[] | undefined>(undefined)
@ -12,6 +16,20 @@ export const useUserBets = (userId: string | undefined) => {
return bets return bets
} }
export const useUserContractBets = (
userId: string | undefined,
contractId: string | undefined
) => {
const [bets, setBets] = useState<Bet[] | undefined>(undefined)
useEffect(() => {
if (userId && contractId)
return listenForUserContractBets(userId, contractId, setBets)
}, [userId, contractId])
return bets
}
export const useUserBetContracts = (userId: string | undefined) => { export const useUserBetContracts = (userId: string | undefined) => {
const [contractIds, setContractIds] = useState<string[] | undefined>() const [contractIds, setContractIds] = useState<string[] | undefined>()

View File

@ -74,6 +74,21 @@ export function listenForUserBets(
}) })
} }
export function listenForUserContractBets(
userId: string,
contractId: string,
setBets: (bets: Bet[]) => void
) {
const betsQuery = query(
collection(db, 'contracts', contractId, 'bets'),
where('userId', '==', userId)
)
return listenForValues<Bet>(betsQuery, (bets) => {
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
setBets(bets)
})
}
export function withoutAnteBets(contract: Contract, bets?: Bet[]) { export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
const { createdTime } = contract const { createdTime } = contract

View File

@ -115,6 +115,41 @@ export function listenForContracts(
return listenForValues<Contract>(q, setContracts) return listenForValues<Contract>(q, setContracts)
} }
const activeContractsQuery = query(
contractCollection,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
where('volume24Hours', '>', 0)
)
export function getActiveContracts() {
return getValues<Contract>(activeContractsQuery)
}
export function listenForActiveContracts(
setContracts: (contracts: Contract[]) => void
) {
return listenForValues<Contract>(activeContractsQuery, setContracts)
}
const inactiveContractsQuery = query(
contractCollection,
where('isResolved', '==', false),
where('closeTime', '>', Date.now()),
where('visibility', '==', 'public'),
where('volume24Hours', '==', 0)
)
export function getInactiveContracts() {
return getValues<Contract>(inactiveContractsQuery)
}
export function listenForInactiveContracts(
setContracts: (contracts: Contract[]) => void
) {
return listenForValues<Contract>(inactiveContractsQuery, setContracts)
}
export function listenForContract( export function listenForContract(
contractId: string, contractId: string,
setContract: (contract: Contract | null) => void setContract: (contract: Contract | null) => void

View File

@ -54,13 +54,12 @@ export async function getFoldBySlug(slug: string) {
} }
function contractsByTagsQuery(tags: string[]) { function contractsByTagsQuery(tags: string[]) {
// TODO: if tags.length > 10, execute multiple parallel queries
const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
return query( return query(
contractCollection, contractCollection,
where( where('lowercaseTags', 'array-contains-any', lowercaseTags)
'lowercaseTags',
'array-contains-any',
tags.map((tag) => tag.toLowerCase())
)
) )
} }
@ -74,7 +73,6 @@ export async function getFoldContracts(fold: Fold) {
} = fold } = fold
const [tagsContracts, includedContracts] = await Promise.all([ const [tagsContracts, includedContracts] = await Promise.all([
// TODO: if tags.length > 10, execute multiple parallel queries
tags.length > 0 ? getValues<Contract>(contractsByTagsQuery(tags)) : [], tags.length > 0 ? getValues<Contract>(contractsByTagsQuery(tags)) : [],
// TODO: if contractIds.length > 10, execute multiple parallel queries // TODO: if contractIds.length > 10, execute multiple parallel queries
@ -163,9 +161,10 @@ export function listenForFollow(
export async function getFoldsByTags(tags: string[]) { export async function getFoldsByTags(tags: string[]) {
if (tags.length === 0) return [] if (tags.length === 0) return []
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const folds = await getValues<Fold>(
// TODO: split into multiple queries if tags.length > 10. // TODO: split into multiple queries if tags.length > 10.
const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
const folds = await getValues<Fold>(
query( query(
foldCollection, foldCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags) where('lowercaseTags', 'array-contains-any', lowercaseTags)
@ -179,10 +178,10 @@ export function listenForFoldsWithTags(
tags: string[], tags: string[],
setFolds: (folds: Fold[]) => void setFolds: (folds: Fold[]) => void
) { ) {
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const q =
// TODO: split into multiple queries if tags.length > 10. // TODO: split into multiple queries if tags.length > 10.
query( const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
const q = query(
foldCollection, foldCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags) where('lowercaseTags', 'array-contains-any', lowercaseTags)
) )

View File

@ -1,10 +1,11 @@
import { getFirestore } from '@firebase/firestore' import { getFirestore } from '@firebase/firestore'
import { initializeApp, getApps, getApp } from 'firebase/app' import { initializeApp, getApps, getApp } from 'firebase/app'
// Used to decide which Stripe instance to point to
export const isProd = process.env.NEXT_PUBLIC_FIREBASE_ENV !== 'DEV' export const isProd = process.env.NEXT_PUBLIC_FIREBASE_ENV !== 'DEV'
const firebaseConfig = isProd const FIREBASE_CONFIGS = {
? { PROD: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com', authDomain: 'mantic-markets.firebaseapp.com',
projectId: 'mantic-markets', projectId: 'mantic-markets',
@ -12,8 +13,8 @@ const firebaseConfig = isProd
messagingSenderId: '128925704902', messagingSenderId: '128925704902',
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D', measurementId: 'G-SSFK1Q138D',
} },
: { DEV: {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com', authDomain: 'dev-mantic-markets.firebaseapp.com',
projectId: 'dev-mantic-markets', projectId: 'dev-mantic-markets',
@ -21,8 +22,20 @@ const firebaseConfig = isProd
messagingSenderId: '134303100058', messagingSenderId: '134303100058',
appId: '1:134303100058:web:27f9ea8b83347251f80323', appId: '1:134303100058:web:27f9ea8b83347251f80323',
measurementId: 'G-YJC9E37P37', measurementId: 'G-YJC9E37P37',
},
THEOREMONE: {
apiKey: 'AIzaSyBSXL6Ys7InNHnCKSy-_E_luhh4Fkj4Z6M',
authDomain: 'theoremone-manifold.firebaseapp.com',
projectId: 'theoremone-manifold',
storageBucket: 'theoremone-manifold.appspot.com',
messagingSenderId: '698012149198',
appId: '1:698012149198:web:b342af75662831aa84b79f',
measurementId: 'G-Y3EZ1WNT6E',
},
} }
const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD'
// @ts-ignore
const firebaseConfig = FIREBASE_CONFIGS[ENV]
// Initialize Firebase // Initialize Firebase
export const app = getApps().length ? getApp() : initializeApp(firebaseConfig) export const app = getApps().length ? getApp() : initializeApp(firebaseConfig)

View File

@ -1,6 +1,5 @@
import { db } from './init' import { db } from './init'
import { import {
doc,
getDoc, getDoc,
getDocs, getDocs,
onSnapshot, onSnapshot,
@ -22,7 +21,9 @@ export function listenForValue<T>(
docRef: DocumentReference, docRef: DocumentReference,
setValue: (value: T | null) => void setValue: (value: T | null) => void
) { ) {
return onSnapshot(docRef, (snapshot) => { // Exclude cached snapshots so we only trigger on fresh data.
// includeMetadataChanges ensures listener is called even when server data is the same as cached data.
return onSnapshot(docRef, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return if (snapshot.metadata.fromCache) return
const value = snapshot.exists() ? (snapshot.data() as T) : null const value = snapshot.exists() ? (snapshot.data() as T) : null
@ -34,7 +35,9 @@ export function listenForValues<T>(
query: Query, query: Query,
setValues: (values: T[]) => void setValues: (values: T[]) => void
) { ) {
return onSnapshot(query, (snapshot) => { // Exclude cached snapshots so we only trigger on fresh data.
// includeMetadataChanges ensures listener is called even when server data is the same as cached data.
return onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.data() as T) const values = snapshot.docs.map((doc) => doc.data() as T)

View File

@ -145,7 +145,7 @@ export default function ContractPage(props: {
<Col className="flex-1"> <Col className="flex-1">
{allowTrade && ( {allowTrade && (
<BetPanel className="hidden lg:inline" contract={contract} /> <BetPanel className="hidden lg:flex" contract={contract} />
)} )}
{allowResolve && ( {allowResolve && (
<ResolutionPanel creator={user} contract={contract} /> <ResolutionPanel creator={user} contract={contract} />
@ -177,12 +177,8 @@ function BetsSection(props: {
return ( return (
<div> <div>
<Title className="px-2" text="Your trades" /> <Title className="px-2" text="Your trades" />
{isBinary && (
<>
<MyBetsSummary className="px-2" contract={contract} bets={userBets} /> <MyBetsSummary className="px-2" contract={contract} bets={userBets} />
<Spacer h={6} /> <Spacer h={6} />
</>
)}
<ContractBetsTable contract={contract} bets={userBets} /> <ContractBetsTable contract={contract} bets={userBets} />
<Spacer h={12} /> <Spacer h={12} />
</div> </div>

View File

@ -139,6 +139,15 @@ function Contents() {
bettors that are correct more often will gain influence, leading to bettors that are correct more often will gain influence, leading to
better-calibrated forecasts over time. better-calibrated forecasts over time.
</p> </p>
<p>
Since our launch, we've seen hundreds of users trade each day, on over a
thousand different markets! You can track the popularity of our platform
at{' '}
<a href="http://manifold.markets/analytics">
http://manifold.markets/analytics
</a>
.
</p>
<h3 id="how-are-markets-resolved-">How are markets resolved?</h3> <h3 id="how-are-markets-resolved-">How are markets resolved?</h3>
<p> <p>
The creator of the prediction market decides the outcome and earns{' '} The creator of the prediction market decides the outcome and earns{' '}

View File

@ -1,14 +1,12 @@
import _ from 'lodash' import _ from 'lodash'
import { ContractFeed } from '../components/contract-feed' import { ContractFeed, ContractSummaryFeed } from '../components/contract-feed'
import { Page } from '../components/page' import { Page } from '../components/page'
import { Contract } from '../lib/firebase/contracts' import { Contract } from '../lib/firebase/contracts'
import { Comment } from '../lib/firebase/comments' import { Comment } from '../lib/firebase/comments'
import { Col } from '../components/layout/col' import { Col } from '../components/layout/col'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { filterDefined } from '../../common/util/array'
const MAX_ACTIVE_CONTRACTS = 75 const MAX_ACTIVE_CONTRACTS = 75
const MAX_HOT_MARKETS = 10
// This does NOT include comment times, since those aren't part of the contract atm. // This does NOT include comment times, since those aren't part of the contract atm.
// TODO: Maybe store last activity time directly in the contract? // TODO: Maybe store last activity time directly in the contract?
@ -25,12 +23,11 @@ function lastActivityTime(contract: Contract) {
// - Comment on a market // - Comment on a market
// - New market created // - New market created
// - Market resolved // - Market resolved
// - Markets with most betting in last 24 hours // - Bet on market
export function findActiveContracts( export function findActiveContracts(
allContracts: Contract[], allContracts: Contract[],
recentComments: Comment[], recentComments: Comment[],
recentBets: Bet[], recentBets: Bet[]
daysAgo = 3
) { ) {
const idToActivityTime = new Map<string, number>() const idToActivityTime = new Map<string, number>()
function record(contractId: string, time: number) { function record(contractId: string, time: number) {
@ -39,52 +36,38 @@ export function findActiveContracts(
idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time)) idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time))
} }
let contracts: Contract[] = [] const contractsById = new Map(allContracts.map((c) => [c.id, c]))
// Find contracts with activity in the last 3 days // Record contract activity.
const DAY_IN_MS = 24 * 60 * 60 * 1000 for (const contract of allContracts) {
for (const contract of allContracts || []) {
if (lastActivityTime(contract) > Date.now() - daysAgo * DAY_IN_MS) {
contracts.push(contract)
record(contract.id, lastActivityTime(contract)) record(contract.id, lastActivityTime(contract))
} }
}
// Add every contract that had a recent comment, too // Add every contract that had a recent comment, too
const contractsById = new Map(allContracts.map((c) => [c.id, c]))
for (const comment of recentComments) { for (const comment of recentComments) {
const contract = contractsById.get(comment.contractId) const contract = contractsById.get(comment.contractId)
if (contract) { if (contract) record(contract.id, comment.createdTime)
contracts.push(contract)
record(contract.id, comment.createdTime)
}
} }
// Add recent top-trading contracts, ordered by last bet. // Add contracts by last bet time.
const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) const contractBets = _.groupBy(recentBets, (bet) => bet.contractId)
const contractTotalBets = _.mapValues(contractBets, (bets) => const contractMostRecentBet = _.mapValues(
_.sumBy(bets, (bet) => bet.amount) contractBets,
(bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet
) )
const sortedPairs = _.sortBy( for (const bet of Object.values(contractMostRecentBet)) {
_.toPairs(contractTotalBets), const contract = contractsById.get(bet.contractId)
([_, total]) => -1 * total if (contract) record(contract.id, bet.createdTime)
)
const topTradedContracts = filterDefined(
sortedPairs.map(([id]) => contractsById.get(id))
).slice(0, MAX_HOT_MARKETS)
for (const contract of topTradedContracts) {
const bet = recentBets.find((bet) => bet.contractId === contract.id)
if (bet) {
contracts.push(contract)
record(contract.id, bet.createdTime)
}
} }
contracts = _.uniqBy(contracts, (c) => c.id) let activeContracts = allContracts.filter(
contracts = contracts.filter((contract) => contract.visibility === 'public') (contract) => contract.visibility === 'public' && !contract.isResolved
contracts = _.sortBy(contracts, (c) => -(idToActivityTime.get(c.id) ?? 0)) )
return contracts.slice(0, MAX_ACTIVE_CONTRACTS) activeContracts = _.sortBy(
activeContracts,
(c) => -(idToActivityTime.get(c.id) ?? 0)
)
return activeContracts.slice(0, MAX_ACTIVE_CONTRACTS)
} }
export function ActivityFeed(props: { export function ActivityFeed(props: {
@ -116,6 +99,24 @@ export function ActivityFeed(props: {
) )
} }
export function SummaryActivityFeed(props: { contracts: Contract[] }) {
const { contracts } = props
return (
<Col className="items-center">
<Col className="w-full max-w-3xl">
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
{contracts.map((contract) => (
<div key={contract.id} className="py-6 px-2 sm:px-4">
<ContractSummaryFeed contract={contract} />
</div>
))}
</Col>
</Col>
</Col>
)
}
export default function ActivityPage() { export default function ActivityPage() {
return ( return (
<Page> <Page>

View File

@ -19,11 +19,11 @@ export default function AddFundsPage() {
<SEO title="Add funds" description="Add funds" url="/add-funds" /> <SEO title="Add funds" description="Add funds" url="/add-funds" />
<Col className="items-center"> <Col className="items-center">
<Col> <Col className="bg-white rounded sm:shadow-md p-4 py-8 sm:p-8 h-full">
<Title text="Get Manifold Dollars" /> <Title className="!mt-0" text="Get Manifold Dollars" />
<img <img
className="mt-6 block" className="mb-6 block self-center -scale-x-100"
src="/praying-mantis-light.svg" src="/stylized-crane-black.png"
width={200} width={200}
height={200} height={200}
/> />
@ -50,7 +50,7 @@ export default function AddFundsPage() {
<form <form
action={checkoutURL(user?.id || '', amountSelected)} action={checkoutURL(user?.id || '', amountSelected)}
method="POST" method="POST"
className="mt-12" className="mt-8"
> >
<button <button
type="submit" type="submit"

View File

@ -26,6 +26,7 @@ export type LiteMarket = {
volume24Hours: number volume24Hours: number
isResolved: boolean isResolved: boolean
resolution?: string resolution?: string
resolutionTime?: number
} }
export type FullMarket = LiteMarket & { export type FullMarket = LiteMarket & {
@ -54,6 +55,7 @@ export function toLiteMarket({
volume24Hours, volume24Hours,
isResolved, isResolved,
resolution, resolution,
resolutionTime,
}: Contract): LiteMarket { }: Contract): LiteMarket {
return { return {
id, id,
@ -61,7 +63,10 @@ export function toLiteMarket({
creatorName, creatorName,
createdTime, createdTime,
creatorAvatarUrl, creatorAvatarUrl,
closeTime, closeTime:
resolutionTime && closeTime
? Math.min(resolutionTime, closeTime)
: closeTime,
question, question,
description, description,
tags, tags,
@ -72,5 +77,6 @@ export function toLiteMarket({
volume24Hours, volume24Hours,
isResolved, isResolved,
resolution, resolution,
resolutionTime,
} }
} }

View File

@ -248,6 +248,7 @@ export function NewContract(props: { question: string; tag?: string }) {
error={anteError} error={anteError}
setError={setAnteError} setError={setAnteError}
disabled={isSubmitting} disabled={isSubmitting}
contractId={undefined}
/> />
</div> </div>

View File

@ -5,12 +5,7 @@ import { Fold } from '../../../../common/fold'
import { Comment } from '../../../../common/comment' import { Comment } from '../../../../common/comment'
import { Page } from '../../../components/page' import { Page } from '../../../components/page'
import { Title } from '../../../components/title' import { Title } from '../../../components/title'
import { import { Bet, listAllBets } from '../../../lib/firebase/bets'
Bet,
getRecentContractBets,
listAllBets,
} from '../../../lib/firebase/bets'
import { listAllComments } from '../../../lib/firebase/comments'
import { Contract } from '../../../lib/firebase/contracts' import { Contract } from '../../../lib/firebase/contracts'
import { import {
foldPath, foldPath,
@ -40,6 +35,7 @@ import FeedCreate from '../../../components/feed-create'
import { SEO } from '../../../components/SEO' import { SEO } from '../../../components/SEO'
import { useTaggedContracts } from '../../../hooks/use-contracts' import { useTaggedContracts } from '../../../hooks/use-contracts'
import { Linkify } from '../../../components/linkify' import { Linkify } from '../../../components/linkify'
import { filterDefined } from '../../../../common/util/array'
export async function getStaticProps(props: { params: { slugs: string[] } }) { export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params const { slugs } = props.params
@ -49,42 +45,21 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : [] const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : []
const betsPromise = Promise.all( const bets = await Promise.all(
contracts.map((contract) => listAllBets(contract.id)) contracts.map((contract) => listAllBets(contract.id))
) )
const betsByContract = _.fromPairs(contracts.map((c, i) => [c.id, bets[i]]))
const [contractComments, contractRecentBets] = await Promise.all([ let activeContracts = findActiveContracts(contracts, [], _.flatten(bets))
Promise.all(
contracts.map((contract) => listAllComments(contract.id).catch((_) => []))
),
Promise.all(
contracts.map((contract) =>
getRecentContractBets(contract.id).catch((_) => [])
)
),
])
let activeContracts = findActiveContracts(
contracts,
_.flatten(contractComments),
_.flatten(contractRecentBets),
365
)
const [resolved, unresolved] = _.partition( const [resolved, unresolved] = _.partition(
activeContracts, activeContracts,
({ isResolved }) => isResolved ({ isResolved }) => isResolved
) )
activeContracts = [...unresolved, ...resolved] activeContracts = [...unresolved, ...resolved]
const activeContractBets = await Promise.all( const activeContractBets = activeContracts.map(
activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) (contract) => betsByContract[contract.id] ?? []
) )
const activeContractComments = activeContracts.map(
(contract) =>
contractComments[contracts.findIndex((c) => c.id === contract.id)]
)
const bets = await betsPromise
const creatorScores = scoreCreators(contracts, bets) const creatorScores = scoreCreators(contracts, bets)
const traderScores = scoreTraders(contracts, bets) const traderScores = scoreTraders(contracts, bets)
@ -102,7 +77,7 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
contracts, contracts,
activeContracts, activeContracts,
activeContractBets, activeContractBets,
activeContractComments, activeContractComments: activeContracts.map(() => []),
traderScores, traderScores,
topTraders, topTraders,
creatorScores, creatorScores,
@ -169,9 +144,11 @@ export default function FoldPage(props: {
taggedContracts.map((contract) => [contract.id, contract]) taggedContracts.map((contract) => [contract.id, contract])
) )
const contracts = props.contracts.map((contract) => contractsMap[contract.id]) const contracts = filterDefined(
const activeContracts = props.activeContracts.map( props.contracts.map((contract) => contractsMap[contract.id])
(contract) => contractsMap[contract.id] )
const activeContracts = filterDefined(
props.activeContracts.map((contract) => contractsMap[contract.id])
) )
if (fold === null || !foldSubpages.includes(page) || slugs[2]) { if (fold === null || !foldSubpages.includes(page) || slugs[2]) {

View File

@ -1,9 +1,12 @@
import React from 'react' import React, { useState } from 'react'
import Router from 'next/router' import Router from 'next/router'
import { SparklesIcon, GlobeAltIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import _ from 'lodash'
import { Contract } from '../lib/firebase/contracts' import { Contract } from '../lib/firebase/contracts'
import { Page } from '../components/page' import { Page } from '../components/page'
import { ActivityFeed } from './activity' import { ActivityFeed, SummaryActivityFeed } from './activity'
import { Comment } from '../lib/firebase/comments' import { Comment } from '../lib/firebase/comments'
import { Bet } from '../lib/firebase/bets' import { Bet } from '../lib/firebase/bets'
import FeedCreate from '../components/feed-create' import FeedCreate from '../components/feed-create'
@ -13,12 +16,15 @@ import { useUser } from '../hooks/use-user'
import { Fold } from '../../common/fold' import { Fold } from '../../common/fold'
import { LoadingIndicator } from '../components/loading-indicator' import { LoadingIndicator } from '../components/loading-indicator'
import { Row } from '../components/layout/row' import { Row } from '../components/layout/row'
import { SparklesIcon } from '@heroicons/react/solid'
import { FastFoldFollowing } from '../components/fast-fold-following' import { FastFoldFollowing } from '../components/fast-fold-following'
import { import {
getAllContractInfo, getAllContractInfo,
useActiveContracts, useExploreContracts,
} from '../hooks/use-active-contracts' useFilterYourContracts,
useFindActiveContracts,
} from '../hooks/use-find-active-contracts'
import { useGetRecentBets } from '../hooks/use-bets'
import { useActiveContracts } from '../hooks/use-contracts'
export async function getStaticProps() { export async function getStaticProps() {
const contractInfo = await getAllContractInfo() const contractInfo = await getAllContractInfo()
@ -35,14 +41,27 @@ const Home = (props: {
recentBets: Bet[] recentBets: Bet[]
recentComments: Comment[] recentComments: Comment[]
}) => { }) => {
const { folds, recentComments } = props
const user = useUser() const user = useUser()
const { const contracts = useActiveContracts() ?? props.contracts
activeContracts, const { yourContracts, initialFollowedFoldSlugs } = useFilterYourContracts(
activeBets, user,
activeComments, folds,
initialFollowedFoldSlugs, contracts
} = useActiveContracts(props, user) )
const recentBets = useGetRecentBets()
const { activeContracts, activeBets, activeComments } =
useFindActiveContracts({
contracts: yourContracts,
recentBets: recentBets ?? [],
recentComments,
})
const exploreContracts = useExploreContracts()
const [feedMode, setFeedMode] = useState<'activity' | 'explore'>('activity')
if (user === null) { if (user === null) {
Router.replace('/') Router.replace('/')
@ -64,14 +83,37 @@ const Home = (props: {
/> />
)} )}
<Spacer h={5} />
<Col className="mx-3 mb-3 gap-2 text-sm text-gray-800 sm:flex-row"> <Col className="mx-3 mb-3 gap-2 text-sm text-gray-800 sm:flex-row">
<Row className="gap-2"> <Row className="gap-2">
<div className="tabs">
<div
className={clsx(
'tab gap-2',
feedMode === 'activity' && 'tab-active'
)}
onClick={() => setFeedMode('activity')}
>
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" /> <SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
<span className="whitespace-nowrap">Recent activity</span> Recent activity
</div>
<div
className={clsx(
'tab gap-2',
feedMode === 'explore' && 'tab-active'
)}
onClick={() => setFeedMode('explore')}
>
<GlobeAltIcon className="inline h-5 w-5" aria-hidden="true" />
Explore
</div>
</div>
</Row> </Row>
</Col> </Col>
{activeContracts ? ( {feedMode === 'activity' &&
(recentBets ? (
<ActivityFeed <ActivityFeed
contracts={activeContracts} contracts={activeContracts}
contractBets={activeBets} contractBets={activeBets}
@ -79,7 +121,14 @@ const Home = (props: {
/> />
) : ( ) : (
<LoadingIndicator className="mt-4" /> <LoadingIndicator className="mt-4" />
)} ))}
{feedMode === 'explore' &&
(exploreContracts ? (
<SummaryActivityFeed contracts={exploreContracts} />
) : (
<LoadingIndicator className="mt-4" />
))}
</Col> </Col>
</Col> </Col>
</Page> </Page>

View File

@ -245,6 +245,7 @@ ${TEST_VALUE}
error={anteError} error={anteError}
setError={setAnteError} setError={setAnteError}
disabled={isSubmitting} disabled={isSubmitting}
contractId={undefined}
/> />
</div> </div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB