Merge branch 'main' into austin/dc-hackathon

This commit is contained in:
Austin Chen 2022-10-04 11:07:39 -04:00
commit 296d85b4cd
139 changed files with 5411 additions and 2658 deletions

View File

@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userName: string
userUsername: string
userAvatarUrl?: string
bountiesAwarded?: number
} & T
export type OnContract = {

View File

@ -62,6 +62,9 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number
unlistedById?: string
} & T
export type BinaryContract = Contract & Binary

View File

@ -11,7 +11,8 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -41,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
}
type FirebaseConfig = {

View File

@ -23,6 +23,7 @@ export type Group = {
score: number
}[]
}
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}
export const MAX_GROUP_NAME_LENGTH = 75

View File

@ -5,4 +5,4 @@ export type Like = {
createdTime: number
tipTxnId?: string // only holds most recent tip txn id
}
export const LIKE_TIP_AMOUNT = 5
export const LIKE_TIP_AMOUNT = 10

View File

@ -12,7 +12,6 @@ import {
visibility,
} from './contract'
import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
@ -38,15 +37,6 @@ export function getNewContract(
answers: string[],
visibility: visibility
) {
const tags = parseTags(
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@ -70,8 +60,8 @@ export function getNewContract(
question: question.trim(),
description,
tags,
lowercaseTags,
tags: [],
lowercaseTags: [],
visibility,
isResolved: false,
createdTime: Date.now(),

View File

@ -96,6 +96,7 @@ type notification_descriptions = {
[key in notification_preference]: {
simple: string
detailed: string
necessary?: boolean
}
}
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -116,8 +117,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching",
},
betting_streaks: {
simple: 'For predictions made over consecutive days',
detailed: 'Bonuses for predictions made over consecutive days',
simple: `For prediction streaks`,
detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
},
comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow',
@ -159,8 +160,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch',
},
profit_loss_updates: {
simple: 'Weekly profit and loss updates',
detailed: 'Weekly profit and loss updates',
simple: 'Weekly portfolio updates',
detailed: 'Weekly portfolio updates',
},
referral_bonuses: {
simple: 'For referring new users',
@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets',
},
your_contract_closed: {
simple: 'Your market has closed and you need to resolve it',
detailed: 'Your market has closed and you need to resolve it',
simple: 'Your market has closed and you need to resolve it (necessary)',
detailed: 'Your market has closed and you need to resolve it (necessary)',
necessary: true,
},
all_comments_on_watched_markets: {
simple: 'All new comments',
@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`,
},
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:
'Opt out of all notifications excluding your own market closure notifications',
},
}
export type BettingStreakData = {

View File

@ -12,7 +12,6 @@ export type Post = {
export type DateDoc = Post & {
bounty: number
birthday: number
photoUrl: string
type: 'date-doc'
contractSlug: string
}

View File

@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus
| BettingStreakBonus
| CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data
data?: { [key: string]: any }
@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
}
}
type CommentBountyDeposit = {
fromType: 'USER'
toType: 'BANK'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
}
}
type CommentBountyWithdrawal = {
fromType: 'BANK'
toType: 'USER'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
commentId: string
}
}
type CommentBountyRefund = {
fromType: 'BANK'
toType: 'USER'
category: 'REFUND_COMMENT_BOUNTY'
data: {
contractId: string
}
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal

View File

@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
}
export const getDefaultNotificationPreferences = (
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
const defaults: notification_preferences = {
// Watched Markets
all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false),
@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = (
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false),
unique_bettors_on_your_contract: constructPref(true, true),
tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false),
} as notification_preferences
opt_out_all: [],
}
return defaults
}
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = (
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}

View File

@ -33,6 +33,8 @@ export type User = {
allTime: number
}
fractionResolvedCorrectly: number
nextLoanCached: number
followerCountCached: number

View File

@ -8,7 +8,14 @@ const formatter = new Intl.NumberFormat('en-US', {
})
export function formatMoney(amount: number) {
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case
const newAmount =
// handle -0 case
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
(amount > 0 ? Math.floor : Math.ceil)(
amount + 0.00000000001 * Math.sign(amount)
)
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
}

View File

@ -1,4 +1,3 @@
import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
@ -24,7 +23,8 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs'
import { uniq } from 'lodash'
import { cloneDeep, uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) {
@ -32,34 +32,6 @@ export function getUrl(text: string) {
return results.length ? results[0].href : null
}
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>
match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
)
const tagSet = new Set()
const uniqueTags: string[] = []
// Keep casing of last tag.
matches.reverse()
for (const tag of matches) {
const lowercase = tag.toLowerCase()
if (!tagSet.has(lowercase)) {
tagSet.add(lowercase)
uniqueTags.push(tag)
}
}
uniqueTags.reverse()
return uniqueTags
}
export function parseWordsAsTags(text: string) {
const taggedText = text
.split(/\s+/)
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
.join(' ')
return parseTags(taggedText)
}
// TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@ -103,8 +75,22 @@ export const exhibitExts = [
Mention,
Iframe,
TiptapTweet,
TiptapSpoiler,
]
export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts)
if (!text) return ''
// remove spoiler tags.
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
}
})
return generateText(newText, exhibitExts)
}
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
data.content?.forEach((d) => dfs(d, f))
f(data)
}

View File

@ -0,0 +1,116 @@
// adapted from @n8body/tiptap-spoiler
import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from '@tiptap/core'
import type { ElementType } from 'react'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
spoilerEditor: {
setSpoiler: () => ReturnType
toggleSpoiler: () => ReturnType
unsetSpoiler: () => ReturnType
}
}
}
export type SpoilerOptions = {
HTMLAttributes: Record<string, any>
spoilerOpenClass: string
spoilerCloseClass?: string
inputRegex: RegExp
pasteRegex: RegExp
as: ElementType
}
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
name: 'spoiler',
inline: true,
group: 'inline',
inclusive: false,
exitable: true,
content: 'inline*',
priority: 1001, // higher priority than other formatting so they go inside
addOptions() {
return {
HTMLAttributes: { 'aria-label': 'spoiler' },
spoilerOpenClass: '',
spoilerCloseClass: undefined,
inputRegex: spoilerInputRegex,
pasteRegex: spoilerPasteRegex,
as: 'span',
editing: false,
}
},
addCommands() {
return {
setSpoiler:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleSpoiler:
() =>
({ commands }) =>
commands.toggleMark(this.name),
unsetSpoiler:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
addInputRules() {
return [
markInputRule({
find: this.options.inputRegex,
type: this.type,
}),
]
},
addPasteRules() {
return [
markPasteRule({
find: this.options.pasteRegex,
type: this.type,
}),
]
},
parseHTML() {
return [
{
tag: 'span',
getAttrs: (node) =>
(node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
},
]
},
renderHTML({ HTMLAttributes }) {
const elem = document.createElement(this.options.as as string)
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
})
).forEach(([attr, val]) => elem.setAttribute(attr, val))
elem.addEventListener('click', () => {
elem.setAttribute('class', this.options.spoilerOpenClass)
})
return elem
},
})

View File

@ -65,21 +65,21 @@ Requires no authorization.
Gets a group by its slug.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets`
@ -158,13 +158,16 @@ Requires no authorization.
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
url: string
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC
mechanism: string // dpm-2 or cpmm-1
probability: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
volume: number
volume7Days: number
@ -408,7 +411,7 @@ Requires no authorization.
type FullMarket = LiteMarket & {
bets: Bet[]
comments: Comment[]
answers?: Answer[]
answers?: Answer[] // dpm-2 markets only
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
}
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
Parameters:
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
- `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules for the market.
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
@ -569,6 +572,12 @@ For numeric markets, you must also provide:
- `min`: The minimum value that the market may resolve to.
- `max`: The maximum value that the market may resolve to.
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
- `initialValue`: An initial value for the market, between min and max, exclusive.
For multiple choice markets, you must also provide:
- `answers`: An array of strings, each of which will be a valid answer for the market.
Example request:
@ -605,15 +614,18 @@ For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
For free response markets:
For free response or multiple choice markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100.
For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to.
- `probabilityInt`: Required if `value` is present. Should be equal to
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
- Otherwise: `(value - min) / (max - min)`
Example request:
@ -757,6 +769,7 @@ Requires no authorization.
## Changelog
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
- 2022-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints

View File

@ -102,7 +102,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question'])
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid;
allow update: if isAdmin();
match /comments/{commentId} {
@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} {

View File

@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
totalContracts: 0,
totalMembers: memberIds.length,
postIds: [],
pinnedItems: [],
}
await groupRef.create(group)

View File

@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async (
)
)
}
export const createBountyNotification = async (
fromUser: User,
toUserId: string,
amount: number,
idempotencyKey: string,
contract: Contract,
commentId?: string
) => {
const privateUser = await getPrivateUser(toUserId)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'tip_received'
)
if (!sendToBrowser) return
const slug = commentId
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: commentId ? commentId : contract.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to comment creator
}

View File

@ -42,7 +42,6 @@ const postSchema = z.object({
// Date doc fields:
bounty: z.number().optional(),
birthday: z.number().optional(),
photoUrl: z.string().optional(),
type: z.string().optional(),
question: z.string().optional(),
})
@ -76,6 +75,8 @@ export const createpost = newEndpoint({}, async (req, auth) => {
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)

View File

@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
}
await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -483,11 +483,7 @@
color: #999;
text-decoration: underline;
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
">our Discord</a>!
</td>
</tr>
</table>

View File

@ -320,7 +320,7 @@
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
And here's some of the biggest changes in your portfolio:
And here's some recent changes in your investments:
</span>
</p>
</div>

View File

@ -20,7 +20,7 @@ import { getNotificationDestinationsForUser } from '../../common/user-notificati
import {
PerContractInvestmentsData,
OverallPerformanceData,
} from 'functions/src/weekly-portfolio-emails'
} from './weekly-portfolio-emails'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@ -643,8 +643,8 @@ export const sendWeeklyPortfolioUpdateEmail = async (
templateData[`question${i + 1}Title`] = investment.questionTitle
templateData[`question${i + 1}Url`] = investment.questionUrl
templateData[`question${i + 1}Prob`] = investment.questionProb
templateData[`question${i + 1}Change`] = investment.questionChange
templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle
templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
})
await sendTemplateEmail(

View File

@ -52,6 +52,7 @@ export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
export * from './close-market'
export * from './update-comment-bounty'
import { health } from './health'
import { transact } from './transact'
@ -65,6 +66,7 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
@ -127,4 +131,6 @@ export {
acceptChallenge as acceptchallenge,
createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
}

View File

@ -7,38 +7,47 @@ export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
.onUpdate(async (change, context) => {
const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
const { openCommentBounties, closeTime, question } = contract
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
let sourceText = ''
if (
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString()
} else if (previousValue.question !== contract.question) {
sourceText = contract.question
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
contract
)
// No need to notify users of resolution, that's handled in resolve-market
return
}
if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
await handleUpdatedCloseTime(previousContract, contract, eventId)
}
})
async function handleUpdatedCloseTime(
previousContract: Contract,
contract: Contract,
eventId: string
) {
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
let sourceText = ''
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
sourceText = contract.closeTime.toString()
} else if (previousContract.question !== contract.question) {
sourceText = contract.question
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
contract
)
}

View File

@ -0,0 +1,27 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
opt_out_all: [],
},
})
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -50,3 +50,5 @@ async function main() {
}
}
main()
export {}

View File

@ -0,0 +1,65 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'dev.manifold.markets'
// Dev API key for Cause Exploration Prizes (@CEP)
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
const GROUP_SLUG = 'cart-contest'
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
return await resp.json()
}
async function getMarketsByGroupId(id: string) {
// API structure: /v0/group/by-id/[id]/markets
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
/* Example curl request:
# Resolve a binary market
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES"}'
*/
async function resolveMarketById(
id: string,
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
) {
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcome,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug(GROUP_SLUG)
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
console.log(
'Number of resolved markets',
markets.filter((m: any) => m.isResolved).length
)
// Resolve each market to NO
for (const market of markets) {
if (!market.isResolved) {
console.log(`Resolving market ${market.url} to NO`)
await resolveMarketById(market.id, 'NO')
}
}
}
main()
export {}

View File

@ -42,6 +42,7 @@ const createGroup = async (
totalContracts: contracts.length,
totalMembers: 1,
postIds: [],
pinnedItems: [],
}
await groupRef.create(group)
// create a GroupMemberDoc for the creator

View File

@ -1,46 +0,0 @@
import * as admin from 'firebase-admin'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from '../../../common/contract'
import { parseTags } from '../../../common/util/parse'
import { getValues } from '../utils'
async function updateContractTags() {
const firestore = admin.firestore()
console.log('Updating contracts tags')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loaded', contracts.length, 'contracts')
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
const tags = uniq([
...parseTags(contract.question + contract.description),
...(contract.tags ?? []),
])
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
console.log(
'Updating tags',
contract.slug,
'from',
contract.tags,
'to',
tags
)
await contractRef.update({
tags,
lowercaseTags,
} as Partial<Contract>)
}
}
if (require.main === module) {
updateContractTags().then(() => process.exit())
}

View File

@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)

View File

@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.')
return
}
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id)
@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = {
const update: Partial<PrivateUser> = {
notificationPreferences: {
...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter(
(destination) => destination !== 'email'
),
[notificationSubscriptionType]: wantsToOptOutAll
? previousDestinations.push('email')
: previousDestinations.filter(
(destination) => destination !== 'email'
),
},
}
await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
res.send(
`
<!DOCTYPE html>
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
if (wantsToOptOutAll) {
res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = {
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = {
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to:
${email} has opted out of receiving unnecessary email notifications
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = {
</div>
</div>
</body>
</html>`
)
} else {
res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Manifold Markets 7th Day Anniversary Gift!</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to:
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
`
)
)
}
},
}

View File

@ -0,0 +1,162 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
import {
CommentBountyDepositTxn,
CommentBountyWithdrawalTxn,
} from '../../common/txn'
import { runTxn } from './transact'
import { Comment } from '../../common/comment'
import { createBountyNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
const awardBodySchema = z.object({
contractId: z.string(),
commentId: z.string(),
amount: z.number().gt(0),
})
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.balance < amount)
throw new APIError(400, 'Insufficient user balance')
const newCommentBountyTxn = {
fromId: user.id,
fromType: 'USER',
toId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
toType: 'BANK',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
},
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
} as CommentBountyDepositTxn
const result = await runTxn(transaction, newCommentBountyTxn)
transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
})
)
return result
})
})
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
const res = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.id !== contract.creatorId)
throw new APIError(
400,
'Only contract creator can award comment bounties'
)
const commentDoc = firestore.doc(
`contracts/${contractId}/comments/${commentId}`
)
const commentSnap = await transaction.get(commentDoc)
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
const comment = commentSnap.data() as Comment
const amountAvailable = contract.openCommentBounties ?? 0
if (amountAvailable < amount)
throw new APIError(400, 'Insufficient open bounty balance')
const newCommentBountyTxn = {
fromId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
fromType: 'BANK',
toId: comment.userId,
toType: 'USER',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
commentId,
},
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
} as CommentBountyWithdrawalTxn
const result = await runTxn(transaction, newCommentBountyTxn)
await transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: amountAvailable - amount,
})
)
await transaction.update(
commentDoc,
removeUndefinedProps({
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
})
)
return { ...result, comment, contract, user }
})
if (res.txn?.id) {
const { comment, contract, user } = res
await createBountyNotification(
user,
comment.userId,
amount,
res.txn.id,
contract,
comment.id
)
}
return res
})
const firestore = admin.firestore()

View File

@ -17,7 +17,8 @@ import {
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
import { Group } from 'common/group'
import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise'
const firestore = admin.firestore()
@ -27,28 +28,46 @@ export const updateMetrics = functions
.onRun(updateMetricsCore)
export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories, groups] =
await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
getValues<Bet>(firestore.collectionGroup('bets')),
getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
),
getValues<Group>(firestore.collection('groups')),
])
console.log('Loading users')
const users = await getValues<User>(firestore.collection('users'))
console.log('Loading contracts')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
console.log('Loading bets')
const contractBets = await batchedWaitAll(
contracts
.filter((c) => c.id)
.map(
(c) => () =>
getValues<Bet>(
firestore.collection('contracts').doc(c.id).collection('bets')
)
),
100
)
const bets = contractBets.flat()
console.log('Loading group contracts')
const contractsByGroup = await Promise.all(
groups.map((group) => {
return getValues(
groups.map((group) =>
getValues(
firestore
.collection('groups')
.doc(group.id)
.collection('groupContracts')
)
})
)
)
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
@ -116,6 +135,28 @@ export async function updateMetricsCore() {
lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const contractRatios = userContracts
.map((contract) => {
if (
!contract.flaggedByUsernames ||
contract.flaggedByUsernames?.length === 0
) {
return 0
}
const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
return contractRatio
})
.filter((ratio) => ratio > 0)
const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
)
let newFractionResolvedCorrectly = 0
if (userContracts.length > 0) {
newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length
}
return {
user,
@ -123,6 +164,7 @@ export async function updateMetricsCore() {
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}
})
@ -144,6 +186,7 @@ export async function updateMetricsCore() {
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
@ -153,6 +196,7 @@ export async function updateMetricsCore() {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
},
@ -224,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => {
}
type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1

View File

@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
const firestore = admin.firestore()
const numberOfDays = 90
const numberOfDays = 180
const getBetsQuery = (startTime: number, endTime: number) =>
firestore

View File

@ -20,8 +20,8 @@ import { sendWeeklyPortfolioUpdateEmail } from './emails'
import { contractUrl } from './utils'
import { Txn } from '../../common/txn'
import { formatMoney } from '../../common/util/format'
import { getContractBetMetrics } from '../../common/calculate'
// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week
export const weeklyPortfolioUpdateEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
@ -36,9 +36,9 @@ const firestore = admin.firestore()
export async function sendPortfolioUpdateEmailsToAllUsers() {
const privateUsers = isProd()
? // ian & stephen's ids
// ? filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
// filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
// ])
await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
@ -48,7 +48,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
return isProd()
? user.notificationPreferences.profit_loss_updates.includes('email') &&
!user.weeklyPortfolioUpdateEmailSent
: true
: user.notificationPreferences.profit_loss_updates.includes('email')
})
// Send emails in batches
.slice(0, 200)
@ -117,7 +117,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id)
if (!user) return
// Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
@ -165,28 +166,43 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
const bets = userBets.filter(
(bet) => bet.contractId === contract.id
)
const previousBets = bets.filter(
(b) => b.createdTime < Date.now() - 7 * DAY_MS
)
const betsInLastWeek = bets.filter(
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
)
const marketProbabilityAWeekAgo =
cpmmContract.prob - cpmmContract.probChanges.week
const currentMarketProbability = cpmmContract.resolutionProbability
? cpmmContract.resolutionProbability
: cpmmContract.prob
const betsValueAWeekAgo = computeInvestmentValueCustomProb(
bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS),
// TODO: returns 0 for resolved markets - doesn't include them
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
previousBets,
contract,
marketProbabilityAWeekAgo
)
const currentBetsValue = computeInvestmentValueCustomProb(
bets,
const currentBetsMadeAWeekAgoValue =
computeInvestmentValueCustomProb(
previousBets,
contract,
currentMarketProbability
)
const betsMadeInLastWeekProfit = getContractBetMetrics(
contract,
currentMarketProbability
)
const marketChange =
currentMarketProbability - marketProbabilityAWeekAgo
betsInLastWeek
).profit
const profit =
betsMadeInLastWeekProfit +
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
return {
currentValue: currentBetsValue,
pastValue: betsValueAWeekAgo,
difference: currentBetsValue - betsValueAWeekAgo,
currentValue: currentBetsMadeAWeekAgoValue,
pastValue: betsMadeAWeekAgoValue,
profit,
contractSlug: contract.slug,
marketProbAWeekAgo: marketProbabilityAWeekAgo,
questionTitle: contract.question,
@ -194,19 +210,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
questionProb: cpmmContract.resolution
? cpmmContract.resolution
: Math.round(cpmmContract.prob * 100) + '%',
questionChange:
(marketChange > 0 ? '+' : '') +
Math.round(marketChange * 100) +
'%',
questionChangeStyle: `color: ${
currentMarketProbability > marketProbabilityAWeekAgo
? 'rgba(0,160,0,1)'
: '#a80000'
profitStyle: `color: ${
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
};`,
} as PerContractInvestmentsData
})
),
(differences) => Math.abs(differences.difference)
(differences) => Math.abs(differences.profit)
).reverse()
log(
@ -218,12 +228,10 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter(
(diff) =>
diff.pastValue > 0.01 &&
Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1%
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
),
(investmentsData: PerContractInvestmentsData) => {
return investmentsData.difference > 0
return investmentsData.profit > 0
}
)
// pick 3 winning investments and 3 losing investments
@ -236,7 +244,9 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
worstInvestments.length === 0 &&
usersToContractsCreated[privateUser.id].length === 0
) {
log('No bets in last week, no market movers, no markets created')
log(
'No bets in last week, no market movers, no markets created. Not sending an email.'
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
@ -253,7 +263,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to user count:', count)
log('sent out emails to users:', count)
})
)
}
@ -262,11 +272,10 @@ export type PerContractInvestmentsData = {
questionTitle: string
questionUrl: string
questionProb: string
questionChange: string
questionChangeStyle: string
profitStyle: string
currentValue: number
pastValue: number
difference: number
profit: number
}
export type OverallPerformanceData = {

View File

@ -1,84 +0,0 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { FundsSelector } from './yes-no-selector'
export function AddFundsButton(props: { className?: string }) {
const { className } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
const location = useLocation()
return (
<>
<label
htmlFor="add-funds"
className={clsx(
'btn btn-xs btn-outline modal-button font-normal normal-case',
className
)}
>
Get M$
</label>
<input type="checkbox" id="add-funds" className="modal-toggle" />
<div className="modal">
<div className="modal-box">
<div className="mb-6 text-xl">Get Mana</div>
<div className="mb-6 text-gray-500">
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector
selected={amountSelected}
onSelect={setAmountSelected}
/>
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">
${Math.round(amountSelected / 100)}.00
</div>
</div>
<div className="modal-action">
<label htmlFor="add-funds" className={clsx('btn btn-ghost')}>
Back
</label>
<form
action={checkoutURL(user?.id || '', amountSelected, location)}
method="POST"
>
<button
type="submit"
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
>
Checkout
</button>
</form>
</div>
</div>
</div>
</>
)
}
// needed in next js
// window not loaded at runtime
const useLocation = () => {
const [href, setHref] = useState('')
useEffect(() => {
setHref(window.location.href)
}, [])
return href
}

View File

@ -0,0 +1,58 @@
import { manaToUSD } from 'common/util/format'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { Button } from './button'
import { Modal } from './layout/modal'
import { FundsSelector } from './yes-no-selector'
export function AddFundsModal(props: {
open: boolean
setOpen(open: boolean): void
}) {
const { open, setOpen } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
return (
<Modal open={open} setOpen={setOpen} className="rounded-md bg-white p-8">
<div className="mb-6 text-xl text-indigo-700">Get Mana</div>
<div className="mb-6 text-gray-700">
Buy mana (M$) to trade in your favorite markets. <br /> (Not redeemable
for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector selected={amountSelected} onSelect={setAmountSelected} />
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">{manaToUSD(amountSelected)}</div>
</div>
<div className="modal-action">
<Button color="gray-white" onClick={() => setOpen(false)}>
Back
</Button>
<form
action={checkoutURL(
user?.id || '',
amountSelected,
window.location.href
)}
method="POST"
>
<Button type="submit" color="gradient">
Checkout
</Button>
</form>
</div>
</Modal>
)
}

View File

@ -1,12 +1,11 @@
import clsx from 'clsx'
import React from 'react'
import React, { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal'
export function AmountInput(props: {
amount: number | undefined
@ -36,21 +35,20 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount)
}
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
return (
<>
<Col className={className}>
<label className="font-sm md:font-lg">
<span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}>
<label className="font-sm md:font-lg relative">
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label}
</span>
<input
className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error',
isMobile ? 'w-24' : '',
'w-24 md:w-auto',
inputClassName
)}
ref={inputRef}
@ -59,7 +57,6 @@ export function AmountInput(props: {
inputMode="numeric"
placeholder="0"
maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''}
disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)}
@ -71,9 +68,16 @@ export function AmountInput(props: {
{error === 'Insufficient balance' ? (
<>
Not enough funds.
<span className="ml-1 text-indigo-500">
<SiteLink href="/add-funds">Buy more?</SiteLink>
</span>
<button
className="ml-1 text-indigo-500 hover:underline hover:decoration-indigo-400"
onClick={() => setAddFundsModalOpen(true)}
>
Buy more?
</button>
<AddFundsModal
open={addFundsModalOpen}
setOpen={setAddFundsModalOpen}
/>
</>
) : (
error
@ -162,7 +166,7 @@ export function BuyAmountInput(props: {
max="205"
value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg only-thumb z-40 my-auto align-middle xl:hidden"
className="range range-lg only-thumb my-auto align-middle xl:hidden"
step="5"
/>
)}

View File

@ -1,139 +0,0 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Col } from '../layout/col'
export function DailyCountChart(props: {
startDate: number
dailyCounts: number[]
small?: boolean
}) {
const { dailyCounts, startDate, small } = props
const { width } = useWindowSize()
const dates = dailyCounts.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyCounts).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
const data = [{ id: 'Count', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/>
</div>
)
}
export function DailyPercentChart(props: {
startDate: number
dailyPercent: number[]
small?: boolean
excludeFirstDays?: number
}) {
const { dailyPercent, startDate, small, excludeFirstDays } = props
const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyPercent)
.map(([date, percent]) => ({
x: date,
y: percent,
}))
.slice(excludeFirstDays ?? 0)
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisLeft={{
format: formatPercent,
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} isPercent />
}}
/>
</div>
)
}
function Tooltip(props: { point: Point; isPercent?: boolean }) {
const { point, isPercent } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong>{' '}
{isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>
)
}

View File

@ -184,16 +184,14 @@ export function AnswerBetPanel(props: {
<Spacer h={6} />
{user ? (
<WarningConfirmationButton
size="xl"
marketType="freeResponse"
amount={betAmount}
warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary'
)}
color={'indigo'}
/>
) : (
<BetSignUpPrompt />

View File

@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(false)
}
const resolutionButtonClass =
resolveOption === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: resolveOption === 'CHOOSE' && answers.length
? 'btn-primary'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return (
<Col className="gap-4 rounded">
<Row className="justify-between">
@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: {
Clear
</button>
)}
<ResolveConfirmationButton
color={
resolveOption === 'CANCEL'
? 'yellow'
: resolveOption === 'CHOOSE' && answers.length
? 'green'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'blue'
: 'indigo'
}
disabled={
!resolveOption ||
(resolveOption === 'CHOOSE' && !answers.length) ||
(resolveOption === 'CHOOSE_MULTIPLE' &&
(!(answers.length > 1) ||
!answers.every((answer) => chosenAnswers[answer] > 0)))
}
onResolve={onResolve}
isSubmitting={isSubmitting}
openModalButtonClass={resolutionButtonClass}
submitButtonClass={resolutionButtonClass}
/>
</Row>
</Col>

View File

@ -1,238 +0,0 @@
import { DatumValue } from '@nivo/core'
import { ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs'
import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
height?: number
}) {
const { contract, bets, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract
const now = Date.now()
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
bets,
contract
)
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? now
)
const { width } = useWindowSize()
const isLargeWidth = !width || width > 800
const labelLength = isLargeWidth ? 50 : 20
// Add a fake datapoint so the line continues to the right
const endTime = latestTime.valueOf()
const times = sortBy([
createdTime,
...bets.map((bet) => bet.createdTime),
endTime,
])
const dateTimes = times.map((time) => new Date(time))
const data = sortedOutcomes.map((outcome) => {
const betProbs = probsByOutcome[outcome]
// Add extra point for contract start and end.
const probs = [0, ...betProbs, betProbs[betProbs.length - 1]]
const points = probs.map((prob, i) => ({
x: dateTimes[i],
y: Math.round(prob * 100),
}))
const answer =
answers?.find((answer) => answer.id === outcome)?.text ?? 'None'
const answerText =
answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '')
return { id: answerText, data: points }
})
data.reverse()
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2
const startDate = dayjs(contract.createdTime)
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
return (
<div
className="w-full"
style={{ height: height ?? (isLargeWidth ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: 100, type: 'linear', stacked: true }}
yFormat={formatPercent}
gridYValues={yTickValues}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'time',
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={[
'#fca5a5', // red-300
'#a5b4fc', // indigo-300
'#86efac', // green-300
'#fef08a', // yellow-200
'#fdba74', // orange-300
'#c084fc', // purple-400
]}
pointSize={0}
curve="stepAfter"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
areaOpacity={1}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
legends={[
{
anchor: 'top-left',
direction: 'column',
justify: false,
translateX: isLargeWidth ? 5 : 2,
translateY: 0,
itemsSpacing: 0,
itemTextColor: 'black',
itemDirection: 'left-to-right',
itemWidth: isLargeWidth ? 288 : 138,
itemHeight: 20,
itemBackground: 'white',
itemOpacity: 0.9,
symbolSize: 12,
effects: [
{
on: 'hover',
style: {
itemBackground: 'rgba(255, 255, 255, 1)',
itemOpacity: 1,
},
},
],
},
]}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
return `${Math.round(+y.toString())}%`
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'
}
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}
const computeProbsByOutcome = (
bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter)
)
return (
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
maxProb > 0.02 &&
totalBets[outcome] > 0.000000001
)
})
const trackedOutcomes = sortBy(
outcomes,
(outcome) => -1 * getOutcomeProbability(contract, outcome)
).slice(0, NUM_LINES)
const probsByOutcome = Object.fromEntries(
trackedOutcomes.map((outcome) => [outcome, [] as number[]])
)
const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
)
for (const bet of bets) {
const { outcome, shares } = bet
sharesByOutcome[outcome] += shares
const sharesSquared = sumBy(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)
for (const outcome of trackedOutcomes) {
probsByOutcome[outcome].push(
sharesByOutcome[outcome] ** 2 / sharesSquared
)
}
}
return { probsByOutcome, sortedOutcomes: trackedOutcomes }
}

View File

@ -8,13 +8,14 @@ export function Avatar(props: {
username?: string
avatarUrl?: string
noLink?: boolean
size?: number | 'xs' | 'sm'
size?: number | 'xxs' | 'xs' | 'sm'
className?: string
}) {
const { username, noLink, size, className } = props
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const s =
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4
const onClick =

View File

@ -0,0 +1,46 @@
import clsx from 'clsx'
import { ContractComment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
export function AwardBountyButton(prop: {
comment: ContractComment
contract: Contract
}) {
const { comment, contract } = prop
const me = useUser()
const submit = () => {
const data = {
amount: COMMENT_BOUNTY_AMOUNT,
commentId: comment.id,
contractId: contract.id,
}
awardCommentBounty(data)
.then((_) => {
console.log('success')
track('award comment bounty', data)
})
.catch((reason) => console.log('Server error:', reason))
track('award comment bounty', data)
}
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div />
return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
<TextButton className={'font-bold'} onClick={submit}>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton>
</Row>
)
}

View File

@ -17,6 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -85,7 +86,12 @@ export function BinaryMobileBetting(props: { contract: BinaryContract }) {
if (user) {
return <SignedInBinaryMobileBetting contract={contract} user={user} />
} else {
return <BetSignUpPrompt className="w-full" />
return (
<Col className="w-full">
<BetSignUpPrompt className="w-full" />
<PlayMoneyDisclaimer />
</Col>
)
}
}

View File

@ -47,7 +47,6 @@ import { Modal } from './layout/modal'
import { Title } from './title'
import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid'
import { useWindowSize } from 'web/hooks/use-window-size'
export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
@ -179,12 +178,7 @@ export function BuyPanel(props: {
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const windowSize = useWindowSize()
const initialOutcome =
windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(
initialOutcome
)
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
@ -395,22 +389,16 @@ export function BuyPanel(props: {
<WarningConfirmationButton
marketType="binary"
amount={betAmount}
outcome={outcome}
warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
openModalButtonClass={clsx(
'btn mb-2 flex-1',
betDisabled || outcome === undefined
? 'btn-disabled bg-greyscale-2'
: outcome === 'NO'
? 'border-none bg-red-400 hover:bg-red-500'
: 'border-none bg-teal-500 hover:bg-teal-600'
)}
disabled={!!betDisabled || outcome === undefined}
size="xl"
color={outcome === 'NO' ? 'red' : 'green'}
/>
)}
<button
className="text-greyscale-6 mx-auto select-none text-sm underline xl:hidden"
className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden"
onClick={() => setSeeLimit(true)}
>
Advanced
@ -419,7 +407,7 @@ export function BuyPanel(props: {
open={seeLimit}
setOpen={setSeeLimit}
position="center"
className="rounded-lg bg-white px-4 pb-8"
className="rounded-lg bg-white px-4 pb-4"
>
<Title text="Limit Order" />
<LimitOrderPanel
@ -428,6 +416,11 @@ export function BuyPanel(props: {
user={user}
unfilledBets={unfilledBets}
/>
<LimitBets
contract={contract}
bets={unfilledBets as LimitBet[]}
className="mt-4"
/>
</Modal>
</Col>
</Col>

View File

@ -2,7 +2,6 @@ import Link from 'next/link'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
@ -46,6 +45,11 @@ import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts'
import { BetsSummary } from './bet-summary'
import { ProfitBadge } from './profit-badge'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('all')
const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
})
const [filter, setFilter] = usePersistentState<BetFilter>('all', {
key: 'bets-list-filter',
store: storageStore(safeLocalStorage()),
})
const [page, setPage] = useState(0)
const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE
@ -599,8 +609,8 @@ function SellButton(props: {
return (
<ConfirmationButton
openModalBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell',
disabled: isSubmitting,
}}
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => {

View File

@ -1,5 +1,6 @@
import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx'
import { LoadingIndicator } from 'web/components/loading-indicator'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType =
@ -21,6 +22,7 @@ export function Button(props: {
color?: ColorType
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
@ -30,6 +32,7 @@ export function Button(props: {
color = 'indigo',
type = 'button',
disabled = false,
loading,
} = props
const sizeClasses = {
@ -46,25 +49,32 @@ export function Button(props: {
<button
type={type}
className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50',
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses,
color === 'green' && 'btn-primary text-white',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' &&
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none',
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none',
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
disabled={disabled}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
{children}
</button>
)

View File

@ -0,0 +1,89 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { getProbability, getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
formatPct,
} from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime),
y: b.probAfter,
obj: b,
}))
}
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatPct(data.y)}</span>
</Row>
)
}
export const BinaryContractChart = (props: {
contract: BinaryContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const startP = getInitialProbability(contract)
const endP = getProbability(contract)
const betPoints = useMemo(() => getBetPoints(bets), [bets])
const data = useMemo(() => {
return [
{ x: new Date(start), y: startP },
...betPoints,
{ x: new Date(end ?? Date.now() + DAY_MS), y: endP },
]
}, [start, startP, end, endP, betPoints])
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
return (
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
yKind="percent"
data={data}
color="#11b981"
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip}
/>
)
}

View File

@ -0,0 +1,227 @@
import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
getDateRange,
getRightmostVisibleDate,
formatPct,
formatDateInRange,
} from '../helpers'
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
const CATEGORY_COLORS = [
'#00b8dd',
'#eecafe',
'#874c62',
'#6457ca',
'#f773ba',
'#9c6bbc',
'#a87744',
'#af8a04',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = (
contract: FreeResponseContract | MultipleChoiceContract,
topN: number
) => {
const { answers, outcomeType, totalBets } = contract
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
)
})
return sortBy(
validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN)
}
const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
)
const points: MultiPoint<Bet>[] = []
for (const bet of sortedBets) {
const { outcome, shares } = bet
sharesByOutcome[outcome] += shares
const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)
points.push({
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
obj: bet,
})
}
return points
}
type LegendItem = { color: string; label: string; value?: string }
const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
<ol className={className}>
{items.map((item) => (
<li key={item.label} className="flex flex-row justify-between gap-4">
<Row className="items-center gap-2 overflow-hidden">
<span
className="h-4 w-4 shrink-0"
style={{ backgroundColor: item.color }}
></span>
<span className="text-semibold overflow-hidden text-ellipsis">
{item.label}
</span>
</Row>
<span className="text-greyscale-6">{item.value}</span>
</li>
))}
</ol>
)
}
export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: MultiPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo(
() => [
{ x: new Date(start), y: answers.map((_) => 0) },
...betPoints,
{
x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
},
],
[answers, contract, betPoints, start, end]
)
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
const ChoiceTooltip = useMemo(
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const legendItems = sortBy(
data.y.map((p, i) => ({
color: CATEGORY_COLORS[i],
label: answers[i].text,
value: formatPct(p),
p,
})),
(item) => -item.p
).slice(0, 10)
return (
<>
<Row className="items-center gap-2">
{data.obj && (
<Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
)}
<span className="text-semibold text-base">
{formatDateInRange(d, start, end)}
</span>
</Row>
<Legend className="max-w-xs" items={legendItems} />
</>
)
},
[answers]
)
return (
<MultiValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
yKind="percent"
data={data}
colors={CATEGORY_COLORS}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip}
/>
)
}

View File

@ -0,0 +1,35 @@
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { BinaryContractChart } from './binary'
import { PseudoNumericContractChart } from './pseudo-numeric'
import { ChoiceContractChart } from './choice'
import { NumericContractChart } from './numeric'
export const ContractChart = (props: {
contract: Contract
bets: Bet[]
width: number
height: number
}) => {
const { contract } = props
switch (contract.outcomeType) {
case 'BINARY':
return <BinaryContractChart {...{ ...props, contract }} />
case 'PSEUDO_NUMERIC':
return <PseudoNumericContractChart {...{ ...props, contract }} />
case 'FREE_RESPONSE':
case 'MULTIPLE_CHOICE':
return <ChoiceContractChart {...{ ...props, contract }} />
case 'NUMERIC':
return <NumericContractChart {...{ ...props, contract }} />
default:
return null
}
}
export {
BinaryContractChart,
PseudoNumericContractChart,
ChoiceContractChart,
NumericContractChart,
}

View File

@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { range } from 'lodash'
import { scaleLinear } from 'd3-scale'
import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { TooltipProps, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract
const step = (max - min) / bucketCount
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
return range(bucketCount).map((i) => ({
x: min + step * (i + 0.5),
y: bucketProbs[`${i}`],
}))
}
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return (
<>
<span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</>
)
}
export const NumericContractChart = (props: {
contract: NumericContract
width: number
height: number
onMouseOver?: (p: DistributionPoint | undefined) => void
}) => {
const { contract, width, height, onMouseOver } = props
const { min, max } = contract
const data = useMemo(() => getNumericChartData(contract), [contract])
const maxY = Math.max(...data.map((d) => d.y))
const xScale = scaleLinear([min, max], [0, width - MARGIN_X])
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
return (
<DistributionChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
data={data}
color={NUMERIC_GRAPH_COLOR}
onMouseOver={onMouseOver}
Tooltip={NumericChartTooltip}
/>
)
}

View File

@ -0,0 +1,110 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { DAY_MS } from 'common/util/time'
import { getInitialProbability, getProbability } from 'common/calculate'
import { formatLargeNumber } from 'common/util/format'
import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import {
TooltipProps,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
} from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
// mqp: note that we have an idiosyncratic version of 'log scale'
// contracts. the values are stored "linearly" and can include zero.
// as a result, we have to do some weird-looking stuff in this code
const getScaleP = (min: number, max: number, isLogScale: boolean) => {
return (p: number) =>
isLogScale
? 10 ** (p * Math.log10(max - min + 1)) + min - 1
: p * (max - min) + min
}
const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime),
y: scaleP(b.probAfter),
obj: b,
}))
}
const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>>
) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row>
)
}
export const PseudoNumericContractChart = (props: {
contract: PseudoNumericContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const { min, max, isLogScale } = contract
const [start, end] = getDateRange(contract)
const scaleP = useMemo(
() => getScaleP(min, max, isLogScale),
[min, max, isLogScale]
)
const startP = scaleP(getInitialProbability(contract))
const endP = scaleP(getProbability(contract))
const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP])
const data = useMemo(
() => [
{ x: new Date(start), y: startP },
...betPoints,
{ x: new Date(end ?? Date.now() + DAY_MS), y: endP },
],
[betPoints, start, startP, end, endP]
)
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
// clamp log scale to make sure zeroes go to the bottom
const yScale = isLogScale
? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true)
: scaleLinear([min, max], [height - MARGIN_Y, 0])
return (
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={xScale}
yScale={yScale}
data={data}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={PseudoNumericChartTooltip}
color={NUMERIC_GRAPH_COLOR}
/>
)
}

View File

@ -0,0 +1,311 @@
import { useCallback, useMemo, useState } from 'react'
import { bisector } from 'd3-array'
import { axisBottom, axisLeft } from 'd3-axis'
import { D3BrushEvent } from 'd3-brush'
import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
import {
CurveFactory,
SeriesPoint,
curveLinear,
stack,
stackOrderReverse,
} from 'd3-shape'
import { range } from 'lodash'
import {
ContinuousScale,
Margin,
SVGChart,
AreaPath,
AreaWithTopStroke,
Point,
TooltipComponent,
computeColorStops,
formatPct,
} from './helpers'
import { useEvent } from 'web/hooks/use-event'
import { formatMoney } from 'common/util/format'
import { nanoid } from 'nanoid'
export type MultiPoint<T = unknown> = Point<Date, number[], T>
export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount'
const getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
}
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
}
}
export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[]
w: number
h: number
color: string
margin: Margin
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => {
const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100)
const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2))
return { xAxis, yAxis }
}, [w, xScale, yScale])
const selector = betAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
})
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
</SVGChart>
)
}
export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
data: P[]
w: number
h: number
colors: readonly string[]
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
}) => {
const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
type SP = SeriesPoint<P>
const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale])
const series = useMemo(() => {
const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, k) => y[k])
.order(stackOrderReverse)
return d3Stack(data)
}, [data])
const selector = betAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
})
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
{series.map((s, i) => (
<AreaPath
key={i}
data={s}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
fill={colors[i]}
/>
))}
</SVGChart>
)
}
export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[]
w: number
h: number
color: string | ((p: P) => string)
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { data, w, h, color, margin, yScale, yKind, curve, Tooltip } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale])
const selector = betAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
})
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
const gradientId = useMemo(() => nanoid(), [])
const stops = useMemo(
() =>
typeof color !== 'string' ? computeColorStops(data, color, px) : null,
[color, data, px]
)
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
{stops && (
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id={gradientId}>
{stops.map((s, i) => (
<stop key={i} offset={`${s.x / w}`} stopColor={s.color} />
))}
</linearGradient>
</defs>
)}
<AreaWithTopStroke
color={typeof color === 'string' ? color : `url(#${gradientId})`}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
</SVGChart>
)
}

View File

@ -0,0 +1,401 @@
import {
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush'
import { area, line, CurveFactory } from 'd3-shape'
import { nanoid } from 'nanoid'
import dayjs from 'dayjs'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
export interface ContinuousScale<T> extends AxisScale<T> {
invert(n: number): T
}
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
export type Margin = {
top: number
right: number
bottom: number
left: number
}
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
const { h, axis } = props
const axisRef = useRef<SVGGElement>(null)
useEffect(() => {
if (axisRef.current != null) {
select(axisRef.current)
.transition()
.duration(250)
.call(axis)
.select('.domain')
.attr('stroke-width', 0)
}
}, [h, axis])
return <g ref={axisRef} transform={`translate(0, ${h})`} />
}
export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
const { w, h, axis } = props
const axisRef = useRef<SVGGElement>(null)
useEffect(() => {
if (axisRef.current != null) {
select(axisRef.current)
.call(axis)
.call((g) =>
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
)
.select('.domain')
.attr('stroke-width', 0)
}
}, [w, h, axis])
return <g ref={axisRef} />
}
const LinePathInternal = <P,>(
props: {
data: P[]
px: number | ((p: P) => number)
py: number | ((p: P) => number)
curve: CurveFactory
} & SVGProps<SVGPathElement>
) => {
const { data, px, py, curve, ...rest } = props
const d3Line = line<P>(px, py).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} fill="none" d={d3Line(data)!} />
}
export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
const AreaPathInternal = <P,>(
props: {
data: P[]
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve: CurveFactory
} & SVGProps<SVGPathElement>
) => {
const { data, px, py0, py1, curve, ...rest } = props
const d3Area = area<P>(px, py0, py1).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} d={d3Area(data)!} />
}
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
export const AreaWithTopStroke = <P,>(props: {
data: P[]
color: string
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve: CurveFactory
}) => {
const { data, color, px, py0, py1, curve } = props
return (
<g>
<AreaPath
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve}
fill={color}
opacity={0.2}
/>
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
</g>
)
}
export const SVGChart = <X, TT>(props: {
children: ReactNode
w: number
h: number
margin: Margin
xAxis: Axis<X>
yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<X, TT>
}) => {
const {
children,
w,
h,
margin,
xAxis,
yAxis,
onMouseOver,
onSelect,
Tooltip,
} = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null)
const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), [])
const justSelected = useRef(false)
useEffect(() => {
if (onSelect != null && overlayRef.current) {
const brush = brushX().extent([
[0, 0],
[innerW, innerH],
])
brush.on('end', (ev) => {
// when we clear the brush after a selection, that would normally cause
// another 'end' event, so we have to suppress it with this flag
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouse(undefined)
if (overlayRef.current) {
select(overlayRef.current).call(brush.clear)
}
} else {
justSelected.current = false
}
})
// mqp: shape-rendering null overrides the default d3-brush shape-rendering
// of `crisp-edges`, which seems to cause graphical glitches on Chrome
// (i.e. the bug where the area fill flickers white)
select(overlayRef.current)
.call(brush)
.select('.selection')
.attr('shape-rendering', 'null')
}
}, [innerW, innerH, onSelect])
const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouse(undefined)
}
}
}
const onPointerLeave = () => {
setMouse(undefined)
}
return (
<div className="relative overflow-hidden">
{mouse && Tooltip && (
<TooltipContainer
setElem={tooltipMeasure.setElem}
margin={margin}
pos={getTooltipPosition(
mouse.x,
mouse.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer>
)}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<clipPath id={clipPathId}>
<rect x={0} y={0} width={innerW} height={innerH} />
</clipPath>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g>
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
</g>
</svg>
</div>
)
}
export type TooltipPosition = { left: number; bottom: number }
export const getTooltipPosition = (
mouseX: number,
mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
) => {
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition
margin: Margin
className?: string
children: React.ReactNode
}) => {
const { setElem, pos, margin, className, children } = props
return (
<div
ref={setElem}
className={clsx(
className,
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
)}
style={{
margin: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
...pos,
}}
>
{children}
</div>
)
}
export const computeColorStops = <P,>(
data: P[],
pc: (p: P) => string,
px: (p: P) => number
) => {
const segments: { x: number; color: string }[] = []
let currOffset = 0
let currColor = pc(data[0])
for (const p of data) {
const c = pc(p)
if (c !== currColor) {
segments.push({ x: currOffset, color: currColor })
currOffset = px(p)
currColor = c
}
}
segments.push({ x: currOffset, color: currColor })
const stops: { x: number; color: string }[] = []
stops.push({ x: segments[0].x, color: segments[0].color })
for (const s of segments.slice(1)) {
stops.push({ x: s.x, color: stops[stops.length - 1].color })
stops.push({ x: s.x, color: s.color })
}
return stops
}
export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract
const isClosed = !!closeTime && Date.now() > closeTime
const endDate = resolutionTime ?? (isClosed ? closeTime : null)
return [createdTime, endDate ?? null] as const
}
export const getRightmostVisibleDate = (
contractEnd: number | null | undefined,
lastActivity: number | null | undefined,
now: number
) => {
if (contractEnd != null) {
return contractEnd
} else if (lastActivity != null) {
// client-DB clock divergence may cause last activity to be later than now
return Math.max(lastActivity, now)
} else {
return now
}
}
export const formatPct = (n: number, digits?: number) => {
return `${(n * 100).toFixed(digits ?? 0)}%`
}
export const formatDate = (
date: Date,
opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean }
) => {
const { includeYear, includeHour, includeMinute } = opts
const d = dayjs(date)
const now = Date.now()
if (
d.add(1, 'minute').isAfter(now) &&
d.subtract(1, 'minute').isBefore(now)
) {
return 'Now'
} else {
const dayName = d.isSame(now, 'day')
? 'Today'
: d.add(1, 'day').isSame(now, 'day')
? 'Yesterday'
: null
let format = dayName ? `[${dayName}]` : 'MMM D'
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}
}
export const formatDateInRange = (d: Date, start: Date, end: Date) => {
const opts = {
includeYear: !dayjs(start).isSame(end, 'year'),
includeHour: dayjs(start).add(8, 'day').isAfter(end),
includeMinute: dayjs(end).diff(start, 'hours') < 2,
}
return formatDate(d, opts)
}

View File

@ -0,0 +1,81 @@
import { useMemo } from 'react'
import { scaleTime, scaleLinear } from 'd3-scale'
import { min, max } from 'lodash'
import dayjs from 'dayjs'
import { formatPercent } from 'common/util/format'
import { Row } from '../layout/row'
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
import { TooltipProps } from './helpers'
import { SizedContainer } from 'web/components/sized-container'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getPoints = (startDate: number, dailyValues: number[]) => {
const startDateDayJs = dayjs(startDate)
return dailyValues.map((y, i) => ({
x: startDateDayJs.add(i, 'day').toDate(),
y: y,
}))
}
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{data.y}</span>
</Row>
)
}
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{formatPercent(data.y)}</span>
</Row>
)
}
export function DailyChart(props: {
startDate: number
dailyValues: number[]
excludeFirstDays?: number
pct?: boolean
}) {
const { dailyValues, startDate, excludeFirstDays, pct } = props
const data = useMemo(
() => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0),
[startDate, dailyValues, excludeFirstDays]
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minDate = min(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxDate = max(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxValue = max(data.map((d) => d.y))!
return (
<SizedContainer fullHeight={250} mobileHeight={250}>
{(width, height) => (
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
yKind={pct ? 'percent' : 'amount'}
data={data}
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
color="#11b981"
/>
)}
</SizedContainer>
)
}

View File

@ -126,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300"
className="px-2 text-gray-400 hover:text-gray-500 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>

View File

@ -1,5 +1,6 @@
import clsx from 'clsx'
import { ReactNode, useState } from 'react'
import { Button, ColorType, SizeType } from './button'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
@ -9,6 +10,9 @@ export function ConfirmationButton(props: {
label: string
icon?: JSX.Element
className?: string
color?: ColorType
size?: SizeType
disabled?: boolean
}
cancelBtn?: {
label?: string
@ -17,11 +21,13 @@ export function ConfirmationButton(props: {
submitBtn?: {
label?: string
className?: string
isSubmitting?: boolean
}
children: ReactNode
onSubmit?: () => void
onOpenChanged?: (isOpen: boolean) => void
onSubmitWithSuccess?: () => Promise<boolean>
disabled?: boolean
}) {
const {
openModalBtn,
@ -31,6 +37,7 @@ export function ConfirmationButton(props: {
children,
onOpenChanged,
onSubmitWithSuccess,
disabled,
} = props
const [open, setOpen] = useState(false)
@ -52,7 +59,7 @@ export function ConfirmationButton(props: {
>
{cancelBtn?.label ?? 'Cancel'}
</div>
<div
<Button
className={clsx('btn', submitBtn?.className)}
onClick={
onSubmitWithSuccess
@ -62,19 +69,29 @@ export function ConfirmationButton(props: {
)
: onSubmit
}
loading={submitBtn?.isSubmitting}
>
{submitBtn?.label ?? 'Submit'}
</div>
</Button>
</Row>
</Col>
</Modal>
<div
className={clsx('btn', openModalBtn.className)}
onClick={() => updateOpen(true)}
<Button
className={openModalBtn.className}
onClick={() => {
if (disabled) {
return
}
updateOpen(true)
}}
disabled={openModalBtn.disabled}
color={openModalBtn.color}
size={openModalBtn.size}
>
{openModalBtn.icon}
{openModalBtn.label}
</div>
</Button>
</>
)
}
@ -84,18 +101,25 @@ export function ResolveConfirmationButton(props: {
isSubmitting: boolean
openModalButtonClass?: string
submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) {
const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } =
props
const {
onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return (
<ConfirmationButton
openModalBtn={{
className: clsx(
'border-none self-start',
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
className: clsx('border-none self-start', openModalButtonClass),
label: 'Resolve',
color: color,
disabled: isSubmitting || disabled,
size: 'xl',
}}
cancelBtn={{
label: 'Back',
@ -103,6 +127,7 @@ export function ResolveConfirmationButton(props: {
submitBtn={{
label: 'Resolve',
className: clsx('border-none', submitButtonClass),
isSubmitting,
}}
onSubmit={onResolve}
>

View File

@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user'
import {
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-grid'
import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import {
@ -50,6 +47,7 @@ export const SORTS = [
{ label: 'Trending', value: 'score' },
{ label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Most popular', value: 'most-popular' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
@ -82,7 +80,7 @@ export function ContractSearch(props: {
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions
highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean
cardUIOptions?: {

View File

@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
itemIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}

View File

@ -0,0 +1,74 @@
import { Contract } from 'common/contract'
import { useUser } from 'web/hooks/use-user'
import { useState } from 'react'
import { addCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button'
export function AddCommentBountyPanel(props: { contract: Contract }) {
const { contract } = props
const { id: contractId, slug } = contract
const user = useUser()
const amount = COMMENT_BOUNTY_AMOUNT
const totalAdded = contract.openCommentBounties ?? 0
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const submit = () => {
if ((user?.balance ?? 0) < amount) {
setError('Insufficient balance')
return
}
setIsLoading(true)
setIsSuccess(false)
addCommentBounty({ amount, contractId })
.then((_) => {
track('offer comment bounty', {
amount,
contractId,
})
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('add comment bounty', { amount, contractId, slug })
}
return (
<>
<div className="mb-4 text-gray-500">
Add a {formatMoney(amount)} bounty for good comments that the creator
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
<Row className={'items-center gap-2'}>
<Button
className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}

View File

@ -0,0 +1,47 @@
import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
export function BountiedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span>
)
}
export function BountiedContractSmallBadge(props: {
contract: Contract
showAmount?: boolean
}) {
const { contract, showAmount } = props
const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return (
<Tooltip
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
)
}
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties
)} currently available.`

View File

@ -46,6 +46,7 @@ export function ContractCard(props: {
hideGroupLink?: boolean
trackingPostfix?: string
noLinkAvatar?: boolean
newTab?: boolean
}) {
const {
showTime,
@ -56,6 +57,7 @@ export function ContractCard(props: {
hideGroupLink,
trackingPostfix,
noLinkAvatar,
newTab,
} = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
@ -189,6 +191,7 @@ export function ContractCard(props: {
}
)}
className="absolute top-0 left-0 right-0 bottom-0"
target={newTab ? '_blank' : '_self'}
/>
</Link>
)}
@ -211,19 +214,23 @@ export function BinaryResolutionOrChance(props: {
const probChanged = before !== after
return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
<Col
className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)}
>
{resolution ? (
<>
<div
className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')}
>
Resolved
<Row className="flex items-start">
<div>
<div
className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')}
>
Resolved
</div>
<BinaryContractOutcomeLabel
contract={contract}
resolution={resolution}
/>
</div>
<BinaryContractOutcomeLabel
contract={contract}
resolution={resolution}
/>
</>
</Row>
) : (
<>
{probAfter && probChanged ? (
@ -388,7 +395,9 @@ export function ContractCardProbChange(props: {
noLinkAvatar?: boolean
className?: string
}) {
const { contract, noLinkAvatar, className } = props
const { noLinkAvatar, className } = props
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
return (
<Col
className={clsx(

View File

@ -4,7 +4,7 @@ import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts, parseTags } from 'common/util/parse'
import { exhibitExts } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin'
import { useUser } from 'web/hooks/use-user'
import { updateContract } from 'web/lib/firebase/contracts'
@ -53,17 +53,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
async function saveDescription() {
if (!editor) return
const tags = parseTags(
`${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
await updateContract(contract.id, {
description: editor.getJSON(),
tags,
lowercaseTags,
})
await updateContract(contract.id, { description: editor.getJSON() })
}
return editing ? (

View File

@ -1,4 +1,9 @@
import { ClockIcon } from '@heroicons/react/outline'
import {
ExclamationIcon,
PencilIcon,
PlusCircleIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Editor } from '@tiptap/react'
import dayjs from 'dayjs'
@ -14,7 +19,7 @@ import { useState } from 'react'
import NewContractBadge from '../new-contract-badge'
import { MiniUserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
@ -28,10 +33,13 @@ import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip'
import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import {
BountiedContractBadge,
BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date'
@ -63,6 +71,8 @@ export function MiscDetails(props: {
</Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge />
) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : (
@ -126,9 +136,10 @@ export function ContractDetails(props: {
</Row>
{/* GROUPS */}
{isMobile && (
<div className="mt-2">
<Row className="mt-2 gap-1">
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} />
</div>
</Row>
)}
</Col>
)
@ -142,6 +153,8 @@ export function MarketSubheader(props: {
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract)
const user = useUser()
const correctResolutionPercentage =
useUserById(creatorId)?.fractionResolvedCorrectly
const isCreator = user?.id === creatorId
const isMobile = useIsMobile()
return (
@ -153,13 +166,14 @@ export function MarketSubheader(props: {
size={9}
className="mr-1.5"
/>
{!disabled && (
<div className="absolute mt-3 ml-[11px]">
<MiniUserFollowButton userId={creatorId} />
</div>
)}
<Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm">
<Row className="w-full justify-between ">
<Row className="w-full space-x-1 ">
{disabled ? (
creatorName
) : (
@ -170,15 +184,25 @@ export function MarketSubheader(props: {
short={isMobile}
/>
)}
{correctResolutionPercentage != null &&
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
<Tooltip text="This creator has a track record of creating contracts that are resolved incorrectly.">
<ExclamationIcon className="h-6 w-6 text-yellow-500" />
</Tooltip>
)}
</Row>
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs">
<Row className="text-2xs text-greyscale-4 flex-wrap gap-2 sm:text-xs">
<CloseOrResolveTime
contract={contract}
resolvedDate={resolvedDate}
isCreator={isCreator}
disabled={disabled}
/>
{!isMobile && (
<MarketGroups contract={contract} disabled={disabled} />
<Row className={'gap-1'}>
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} />
</Row>
)}
</Row>
</Col>
@ -190,8 +214,9 @@ export function CloseOrResolveTime(props: {
contract: Contract
resolvedDate: any
isCreator: boolean
disabled?: boolean
}) {
const { contract, resolvedDate, isCreator } = props
const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) {
return (
@ -215,6 +240,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
disabled={disabled}
/>
</Row>
)}
@ -235,7 +261,8 @@ export function MarketGroups(props: {
return (
<>
<Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} />
<GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && (
<button
className="text-greyscale-4 hover:text-greyscale-3"
@ -320,19 +347,34 @@ export function ExtraMobileContractDetails(props: {
)
}
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) {
const { groupToDisplay } = props
export function GroupDisplay(props: {
groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
if (groupToDisplay) {
return (
const groupSection = (
<a
className={clsx(
'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
!disabled && 'hover:bg-greyscale-3 cursor-pointer'
)}
>
{groupToDisplay.name}
</a>
)
return disabled ? (
groupSection
) : (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]">
{groupToDisplay.name}
</a>
{groupSection}
</Link>
)
} else
return (
<div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white">
<div className="bg-greyscale-4 truncate rounded-full py-0.5 px-2 text-xs text-white">
No Group
</div>
)
@ -342,8 +384,9 @@ function EditableCloseDate(props: {
closeTime: number
contract: Contract
isCreator: boolean
disabled?: boolean
}) {
const { closeTime, contract, isCreator } = props
const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs()
@ -356,18 +399,22 @@ function EditableCloseDate(props: {
closeTime && dayJsCloseTime.format('HH:mm')
)
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => {
let newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
function onSave(customTime?: number) {
if (customTime) {
newCloseTime = customTime
setCloseDate(dayjs(newCloseTime).format('YYYY-MM-DD'))
setCloseHoursMinutes(dayjs(newCloseTime).format('HH:mm'))
}
if (!newCloseTime) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) {
else {
const content = contract.description
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
@ -416,22 +463,30 @@ function EditableCloseDate(props: {
/>
</Row>
<Button
className="mt-2"
className="mt-4"
size={'xs'}
color={'indigo'}
onClick={onSave}
onClick={() => onSave()}
>
Done
</Button>
<Button
className="mt-4"
size={'xs'}
color={'gray-white'}
onClick={() => onSave(Date.now())}
>
Close Now
</Button>
</Col>
</Modal>
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
>
<span
className={isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)}
<Row
className={clsx(!disabled && isCreator ? 'cursor-pointer' : '')}
onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
>
{isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span>
@ -440,8 +495,11 @@ function EditableCloseDate(props: {
) : (
dayJsCloseTime.format('MMM D, YYYY')
)}
</span>
{isCreator && !disabled && <PencilIcon className="ml-1 h-4 w-4" />}
</Row>
</DateTimeTooltip>
</>
)
}
const BAD_CREATOR_THRESHOLD = 0.8

View File

@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Title } from '../title'
@ -19,7 +19,7 @@ import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row'
import { BETTORS } from 'common/user'
import { BETTORS, User } from 'common/user'
import { Button } from '../button'
export const contractDetailsButtonClassName =
@ -27,9 +27,10 @@ export const contractDetailsButtonClassName =
export function ContractInfoDialog(props: {
contract: Contract
user: User | null | undefined
className?: string
}) {
const { contract, className } = props
const { contract, className, user } = props
const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState(
@ -37,6 +38,11 @@ export function ContractInfoDialog(props: {
)
const isDev = useDev()
const isAdmin = useAdmin()
const isCreator = user?.id === contract.creatorId
const isUnlisted = contract.visibility === 'unlisted'
const wasUnlistedByCreator = contract.unlistedById
? contract.unlistedById === contract.creatorId
: false
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
@ -168,22 +174,28 @@ export function ContractInfoDialog(props: {
<td>[ADMIN] Featured</td>
<td>
<ShortToggle
enabled={featured}
setEnabled={setFeatured}
on={featured}
setOn={setFeatured}
onChange={onFeaturedToggle}
/>
</td>
</tr>
)}
{isAdmin && (
{user && (
<tr>
<td>[ADMIN] Unlisted</td>
<td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td>
<td>
<ShortToggle
enabled={contract.visibility === 'unlisted'}
setEnabled={(b) =>
disabled={
isUnlisted
? !(isAdmin || (isCreator && wasUnlistedByCreator))
: !(isCreator || isAdmin)
}
on={contract.visibility === 'unlisted'}
setOn={(b) =>
updateContract(id, {
visibility: b ? 'unlisted' : 'public',
unlistedById: b ? user.id : '',
})
}
/>
@ -196,9 +208,7 @@ export function ContractInfoDialog(props: {
<Row className="flex-wrap">
<DuplicateContractButton contract={contract} />
</Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} />
)}
{!contract.resolution && <LiquidityBountyPanel contract={contract} />}
</Col>
</Modal>
</>

View File

@ -1,8 +1,6 @@
import React from 'react'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { ContractProbGraph } from './contract-prob-graph'
import { ContractChart } from 'web/components/charts/contract'
import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
@ -14,7 +12,6 @@ import {
} from './contract-card'
import { Bet } from 'common/bet'
import BetButton, { BinaryMobileBetting } from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph'
import {
Contract,
CPMMContract,
@ -25,7 +22,8 @@ import {
BinaryContract,
} from 'common/contract'
import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph'
import { ContractReportResolution } from './contract-report-resolution'
import { SizedContainer } from 'web/components/sized-container'
const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
@ -45,8 +43,29 @@ const BetWidget = (props: { contract: CPMMContract }) => {
)
}
const NumericOverview = (props: { contract: NumericContract }) => {
const { contract } = props
const SizedContractChart = (props: {
contract: Contract
bets: Bet[]
fullHeight: number
mobileHeight: number
}) => {
const { fullHeight, mobileHeight, contract, bets } = props
return (
<SizedContainer fullHeight={fullHeight} mobileHeight={mobileHeight}>
{(width, height) => (
<ContractChart
width={width}
height={height}
contract={contract}
bets={bets}
/>
)}
</SizedContainer>
)
}
const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
@ -63,7 +82,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract}
/>
</Col>
<NumericGraph contract={contract} />
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col>
)
}
@ -76,14 +100,24 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance
className="flex items-end"
contract={contract}
large
/>
<Row>
<BinaryResolutionOrChance
className="flex items-end"
contract={contract}
large
/>
{contract.isResolved && (
<ContractReportResolution contract={contract} />
)}
</Row>
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
<Row className="items-center justify-between gap-4 xl:hidden">
{tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} />
@ -105,12 +139,21 @@ const ChoiceOverview = (props: {
<ContractDetails contract={contract} />
<OverviewQuestion text={question} />
{resolution && (
<FreeResponseResolutionOrChance contract={contract} truncate="none" />
<Row>
<FreeResponseResolutionOrChance
contract={contract}
truncate="none"
/>
<ContractReportResolution contract={contract} />
</Row>
)}
</Col>
<Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
</Col>
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col>
)
}
@ -136,7 +179,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col>
)
}
@ -150,7 +198,7 @@ export const ContractOverview = (props: {
case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC':
return <NumericOverview contract={contract} />
return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE':

View File

@ -1,203 +0,0 @@
import { DatumValue } from '@nivo/core'
import { ResponsiveLine, SliceTooltipProps } from '@nivo/line'
import { BasicTooltip } from '@nivo/tooltip'
import dayjs from 'dayjs'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size'
import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: {
contract: BinaryContract | PseudoNumericContract
bets: Bet[]
height?: number
}) {
const { contract, height } = props
const { resolutionTime, closeTime, outcomeType } = contract
const now = Date.now()
const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
const startProb = getInitialProbability(contract)
const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
const f: (p: number) => number = isBinary
? (p) => p
: isLogScale
? (p) => p * Math.log10(contract.max - contract.min + 1)
: (p) => p * (contract.max - contract.min) + contract.min
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? now
)
// Add a fake datapoint so the line continues to the right
times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1])
const { width } = useWindowSize()
const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100]
const yTickValues = isBinary
? quartiles
: quartiles.map((x) => x / 100).map(f)
const numXTickValues = !width || width < 800 ? 2 : 5
const startDate = dayjs(times[0])
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
// Minimum number of points for the graph to have. For smooth tooltip movement
// If we aren't actually loading any data yet, skip adding extra points to let page load faster
// This fn runs again once DOM is finished loading
const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1
for (let i = 0; i < times.length - 1; i++) {
const p = probs[i]
const d0 = times[i]
const d1 = times[i + 1]
const msDiff = d1 - d0
const numPoints = Math.floor(msDiff / timeStep)
points.push({ x: new Date(times[i]), y: s * p })
if (numPoints > 1) {
const thisTimeStep: number = msDiff / numPoints
for (let n = 1; n < numPoints; n++) {
points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
}
}
}
const data = [
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
]
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
const formatter = isBinary
? formatPercent
: isLogScale
? (x: DatumValue) =>
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return (
<div
className="w-full overflow-visible"
style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }}
>
<ResponsiveLine
data={data}
yScale={
isBinary
? { min: 0, max: 100, type: 'linear' }
: isLogScale
? {
min: 0,
max: Math.log10(contract.max - contract.min + 1),
type: 'linear',
}
: { min: contract.min, max: contract.max, type: 'linear' }
}
yFormat={formatter}
gridYValues={yTickValues}
axisLeft={{
tickValues: yTickValues,
format: formatter,
}}
xScale={{
type: 'time',
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ datum: 'color' }}
curve="stepAfter"
enablePoints={false}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={false}
enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
animate={false}
sliceTooltip={SliceTooltip}
/>
</div>
)
})
const SliceTooltip = ({ slice }: SliceTooltipProps) => {
return (
<BasicTooltip
id={slice.points.map((point) => [
<span key="date">
<strong>{point.data[`yFormatted`]}</strong> {point.data['xFormatted']}
</span>,
])}
/>
)
}
function formatPercent(y: DatumValue) {
return `${Math.round(+y.toString())}%`
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'
}
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}

View File

@ -0,0 +1,77 @@
import { Contract } from 'common/contract'
import { useUser } from 'web/hooks/use-user'
import clsx from 'clsx'
import { updateContract } from 'web/lib/firebase/contracts'
import { Tooltip } from '../tooltip'
import { ConfirmationButton } from '../confirmation-button'
import { Row } from '../layout/row'
import { FlagIcon } from '@heroicons/react/solid'
import { buildArray } from 'common/util/array'
import { useState } from 'react'
export function ContractReportResolution(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const [reporting, setReporting] = useState(false)
if (!user) {
return <></>
}
const userReported = contract.flaggedByUsernames?.includes(user.id)
const onSubmit = async () => {
if (!user || userReported) {
return true
}
setReporting(true)
await updateContract(contract.id, {
flaggedByUsernames: buildArray(contract.flaggedByUsernames, user.id),
})
setReporting(false)
return true
}
const flagClass = clsx(
'mx-2 flex flex-col items-center gap-1 w-6 h-6 rounded-md !bg-gray-100 px-2 py-1 hover:bg-gray-300',
userReported ? '!text-red-500' : '!text-gray-500'
)
return (
<Tooltip
text={
userReported
? "You've reported this market as incorrectly resolved"
: 'Flag this market as incorrectly resolved '
}
>
<ConfirmationButton
openModalBtn={{
label: '',
icon: <FlagIcon className="h-5 w-5" />,
className: clsx(flagClass, reporting && 'btn-disabled loading'),
}}
cancelBtn={{
label: 'Cancel',
className: 'border-none btn-sm btn-ghost self-center',
}}
submitBtn={{
label: 'Submit',
className: 'btn-secondary',
}}
onSubmitWithSuccess={onSubmit}
disabled={userReported}
>
<div>
<Row className="items-center text-xl">
Flag this market as incorrectly resolved
</Row>
<Row className="text-sm text-gray-500">
Report that the market was not resolved according to its resolution
criteria. If a creator's markets get flagged too often, they'll be
marked as unreliable.
</Row>
</div>
</ConfirmationButton>
</Tooltip>
)
}

View File

@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy } from 'lodash'
import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user'
@ -23,13 +23,27 @@ import {
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip'
import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge'
import { Row } from '../layout/row'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
export function ContractTabs(props: {
contract: Contract
bets: Bet[]
userBets: Bet[]
comments: ContractComment[]
}) {
const { contract, bets, userBets } = props
const { contract, bets, userBets, comments } = props
const yourTrades = (
<div>
@ -42,9 +56,9 @@ export function ContractTabs(props: {
const tabs = buildArray(
{
title: 'Comments',
content: <CommentsTabContent contract={contract} />,
content: <CommentsTabContent contract={contract} comments={comments} />,
},
{
bets.length > 0 && {
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
@ -61,27 +75,91 @@ export function ContractTabs(props: {
const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract
comments: ContractComment[]
}) {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id)
const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
key: `contract-${contract.id}-comments-sort`,
store: storageStore(safeLocalStorage()),
})
const me = useUser()
if (comments == null) {
return <LoadingIndicator />
}
const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
// replied to answers/comments are NOT newest, otherwise newest first
const shouldBeNewestFirst = (c: ContractComment) =>
c.replyToCommentId == undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? c.betId === undefined && c.answerOutcome == undefined
: true)
// TODO: links to comments are broken because tips load after render and
// comments will reorganize themselves if there are tips/bounties awarded
const sortedComments = sortBy(comments, [
sort === 'Best'
? (c) =>
// Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
tipsOrBountiesAwarded &&
c.createdTime > Date.now() - 10 * MINUTE_MS &&
c.userId === me?.id &&
shouldBeNewestFirst(c)
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
: (c) => c,
(c) => (!shouldBeNewestFirst(c) ? c.createdTime : -c.createdTime),
])
const commentsByParent = groupBy(
sortedComments,
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? []
const sortRow = comments.length > 0 && (
<Row className="mb-4 items-center">
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={
sort === 'Best'
? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: ''
}
>
Sort by: {sort}
</Tooltip>
</Button>
<BountiedContractSmallBadge contract={contract} showAmount />
</Row>
)
if (contract.outcomeType === 'FREE_RESPONSE') {
const generalComments = comments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
comments,
sortedComments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
return (
<>
{sortRow}
{sortedAnswers.map((answer) => (
<div key={answer.id} className="relative pb-4">
<span
@ -91,10 +169,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
answerComments={commentsByOutcome[answer.number.toString()] ?? []}
tips={tips}
/>
</div>
@ -102,13 +177,14 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} />
{generalComments.map((comment) => (
{generalTopLevelComments.map((comment) => (
<FeedCommentThread
key={comment.id}
contract={contract}
parentComment={comment}
threadComments={[]}
threadComments={commentsByParent[comment.id] ?? []}
tips={tips}
/>
))}
@ -116,12 +192,11 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
</>
)
} else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = commentsByParent['_'] ?? []
return (
<>
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}

View File

@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract'
export type ContractHighlightOptions = {
contractIds?: string[]
export type CardHighlightOptions = {
itemIds?: string[]
highlightClassName?: string
}
@ -28,7 +28,7 @@ export function ContractsGrid(props: {
noLinkAvatar?: boolean
showProbChange?: boolean
}
highlightOptions?: ContractHighlightOptions
highlightOptions?: CardHighlightOptions
trackingPostfix?: string
breakpointColumns?: { [key: string]: number }
}) {
@ -43,7 +43,7 @@ export function ContractsGrid(props: {
} = props
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback(
(visible) => {
if (visible && loadMore) {

View File

@ -18,9 +18,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
return (
<Row>
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<LikeMarketButton contract={contract} user={user} />
<Tooltip text="Share" placement="bottom" noTap noFade>
<Button
size="sm"
@ -37,7 +35,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/>
</Button>
</Tooltip>
<ContractInfoDialog contract={contract} />
<ContractInfoDialog contract={contract} user={user} />
</Row>
)
}

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes'
@ -8,74 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
import { Tooltip } from '../tooltip'
import { TipButton } from './tip-button'
export function LikeMarketButton(props: {
contract: Contract
user: User | null | undefined
}) {
const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id
)
const tips = useMarketTipTxns(contract.id)
const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount))
}, [tips])
const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes
?.filter((l) => l.type === 'contract')
.map((l) => l.id)
const onLike = async () => {
if (!user) return firebaseLogin()
await likeContract(user, contract)
setIsLiking(true)
likeContract(user, contract).catch(() => setIsLiking(false))
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
}
return (
<Tooltip
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
<TipButton
onClick={onLike}
tipAmount={LIKE_TIP_AMOUNT}
totalTipped={totalTipped}
userTipped={
!!user &&
(isLiking ||
userLikedContractIds?.includes(contract.id) ||
(!likes && !!contract.likedByUserIds?.includes(user.id)))
}
disabled={contract.creatorId === user?.id}
/>
)
}

View File

@ -1,27 +1,30 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { CPMMContract } from 'common/contract'
import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from './amount-input'
import { Row } from './layout/row'
import { AmountInput } from 'web/components/amount-input'
import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs'
import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col'
import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip'
import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin'
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
export function LiquidityPanel(props: { contract: CPMMContract }) {
export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser()
const lpShares = useUserLiquidity(contract, user?.id ?? '')
// eslint-disable-next-line react-hooks/rules-of-hooks
const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '')
const [showWithdrawal, setShowWithdrawal] = useState(false)
@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return (
<Tabs
tabs={buildArray(
(isCreator || isAdmin) && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
showWithdrawal && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
{
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
}
title: 'Bounty Comments',
content: <AddCommentBountyPanel contract={contract} />,
},
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
showWithdrawal &&
isCPMM && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
(isCreator || isAdmin) &&
isCPMM && {
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
}
)}
/>
)

View File

@ -1,99 +0,0 @@
import { DatumValue } from '@nivo/core'
import { Point, ResponsiveLine } from '@nivo/line'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { memo } from 'react'
import { range } from 'lodash'
import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
import { NumericContract } from '../../../common/contract'
import { useWindowSize } from '../../hooks/use-window-size'
import { Col } from '../layout/col'
import { formatLargeNumber } from 'common/util/format'
export const NumericGraph = memo(function NumericGraph(props: {
contract: NumericContract
height?: number
}) {
const { contract, height } = props
const { totalShares, bucketCount, min, max } = contract
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
const xs = range(bucketCount).map(
(i) => min + ((max - min) * i) / bucketCount
)
const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100)
const points = probs.map((prob, i) => ({ x: xs[i], y: prob }))
const maxProb = Math.max(...probs)
const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
const yTickValues = [
0,
0.25 * maxProb,
0.5 & maxProb,
0.75 * maxProb,
maxProb,
]
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: maxProb, type: 'linear' }}
yFormat={formatPercent}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'linear',
min: min,
max: max,
}}
xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
axisBottom={{
tickValues: numXTickValues,
format: (d) => `${formatLargeNumber(+d, 3)}`,
}}
colors={{ datum: 'color' }}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
const p = Math.round(+y * 100) / 100
return `${p}%`
}
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{formatLargeNumber(+point.data.x)}</div>
</Col>
)
}

View File

@ -1,5 +1,5 @@
import { sortBy } from 'lodash'
import clsx from 'clsx'
import { partition } from 'lodash'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
@ -7,6 +7,7 @@ import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
import { useContractWithPreload } from 'web/hooks/use-contract'
export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined
@ -16,16 +17,14 @@ export function ProbChangeTable(props: {
if (!changes) return <LoadingIndicator />
const [positiveChanges, negativeChanges] = partition(
changes,
(c) => c.probChanges.day > 0
)
const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse()
const ascendingChanges = sortBy(changes, (c) => c.probChanges.day)
const threshold = 0.01
const positiveAboveThreshold = positiveChanges.filter(
const positiveAboveThreshold = descendingChanges.filter(
(c) => c.probChanges.day > threshold
)
const negativeAboveThreshold = negativeChanges.filter(
const negativeAboveThreshold = ascendingChanges.filter(
(c) => c.probChanges.day < threshold
)
const maxRows = Math.min(
@ -59,7 +58,9 @@ export function ProbChangeRow(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
const { className } = props
const contract =
(useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
return (
<Row
className={clsx(

View File

@ -0,0 +1,61 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney } from 'common/util/format'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
export function TipButton(props: {
tipAmount: number
totalTipped: number
onClick: () => void
userTipped: boolean
isCompact?: boolean
disabled?: boolean
}) {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
props
return (
<Tooltip
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={clsx(
'max-w-xs self-center',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick}
disabled={disabled}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
)
}

View File

@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal'
import {
CheckIcon,
CodeIcon,
EyeOffIcon,
PhotographIcon,
PresentationChartLineIcon,
TrashIcon,
@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon'
import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
const DisplayImage = Image.configure({
HTMLAttributes: {
@ -107,6 +109,9 @@ export function useTextEditor(props: {
}),
Iframe,
TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
],
content: defaultValue,
})
@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) {
const isBold = editor.isActive('bold')
const isItalic = editor.isActive('italic')
const isLink = editor.isActive('link')
const isSpoiler = editor.isActive('spoiler')
const setLink = () => {
const href = url && getUrl(url)
@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) {
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
</button>
<button onClick={() => editor.chain().focus().toggleSpoiler().run()}>
<EyeOffIcon
className={clsx('h-5', isSpoiler && 'text-indigo-200')}
/>
</button>
</>
) : (
<>
@ -329,6 +340,11 @@ export function RichContent(props: {
}),
Iframe,
TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text',
spoilerCloseClass:
'rounded-sm bg-greyscale-6 text-transparent [&_*]:invisible cursor-pointer select-none',
}),
],
content,
editable: false,

View File

@ -19,6 +19,7 @@ import { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button'
export type ReplyTo = { id: string; username: string }
@ -85,6 +86,7 @@ export function FeedComment(props: {
commenterPositionShares,
commenterPositionOutcome,
createdTime,
bountiesAwarded,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
@ -93,6 +95,7 @@ export function FeedComment(props: {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const totalAwarded = bountiesAwarded ?? 0
const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`)
@ -162,6 +165,11 @@ export function FeedComment(props: {
createdTime={createdTime}
elementId={comment.id}
/>
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div>
<Content
className="mt-2 text-[15px] text-gray-700"
@ -169,7 +177,6 @@ export function FeedComment(props: {
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
{tips && <Tipper comment={comment} tips={tips} />}
{onReplyClick && (
<button
className="font-bold hover:underline"
@ -178,6 +185,10 @@ export function FeedComment(props: {
Reply
</button>
)}
{tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</div>
</Row>
@ -208,28 +219,32 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void
}) {
const user = useUser()
const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } =
props
const { openCommentBounties } = contract
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnContract(
props.contract.id,
contract.id,
editor.getJSON(),
user,
props.parentAnswerOutcome,
props.parentCommentId
!!openCommentBounties,
parentAnswerOutcome,
parentCommentId
)
props.onSubmitComment?.()
}
return (
<CommentInput
replyTo={props.replyTo}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
replyTo={replyTo}
parentAnswerOutcome={parentAnswerOutcome}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
className={className}
/>
)
}

View File

@ -82,11 +82,8 @@ export function CreateGroupButton(props: {
openModalBtn={{
label: label ? label : 'Create Group',
icon: icon,
className: clsx(
isSubmitting ? 'loading btn-disabled' : 'btn-primary',
'btn-sm, normal-case',
className
),
className: className,
disabled: isSubmitting,
}}
submitBtn={{
label: 'Create',

View File

@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react'
import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: {
export function GroupOverviewPost(props: {
group: Group
isEditable: boolean
post: Post | null

View File

@ -0,0 +1,383 @@
import { track } from '@amplitude/analytics-browser'
import {
ArrowSmRightIcon,
PlusCircleIcon,
XCircleIcon,
} from '@heroicons/react/outline'
import PencilIcon from '@heroicons/react/solid/PencilIcon'
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useEffect, useState } from 'react'
import { ReactNode } from 'react'
import { getPost } from 'web/lib/firebase/posts'
import { ContractSearch } from '../contract-search'
import { ContractCard } from '../contract/contract-card'
import Masonry from 'react-masonry-css'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { SiteLink } from '../site-link'
import { GroupOverviewPost } from './group-overview-post'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { groupPath, updateGroup } from 'web/lib/firebase/groups'
import { PinnedSelectModal } from '../pinned-select-modal'
import { Button } from '../button'
import { User } from 'common/user'
import { UserLink } from '../user-link'
import { EditGroupButton } from './edit-group-button'
import { JoinOrLeaveGroupButton } from './groups-button'
import { Linkify } from '../linkify'
import { ChoicesToggleGroup } from '../choices-toggle-group'
import { CopyLinkButton } from '../copy-link-button'
import { REFERRAL_AMOUNT } from 'common/economy'
import toast from 'react-hot-toast'
import { ENV_CONFIG } from 'common/envs/constants'
import { PostCard } from '../post-card'
import { LoadingIndicator } from '../loading-indicator'
const MAX_TRENDING_POSTS = 6
export function GroupOverview(props: {
group: Group
isEditable: boolean
posts: Post[]
aboutPost: Post | null
creator: User
user: User | null | undefined
memberIds: string[]
}) {
const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
props
return (
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
<GroupOverviewPinned
group={group}
posts={posts}
isEditable={isEditable}
/>
{(group.aboutPostId != null || isEditable) && (
<>
<SectionHeader label={'About'} href={'/post/' + group.slug} />
<GroupOverviewPost
group={group}
isEditable={isEditable}
post={aboutPost}
/>
</>
)}
<SectionHeader label={'Trending'} />
<ContractSearch
user={user}
defaultSort={'score'}
noControls
maxResults={MAX_TRENDING_POSTS}
defaultFilter={'all'}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-trending-${group.slug}`}
/>
<GroupAbout
group={group}
creator={creator}
isEditable={isEditable}
user={user}
memberIds={memberIds}
/>
</Col>
)
}
function GroupOverviewPinned(props: {
group: Group
posts: Post[]
isEditable: boolean
}) {
const { group, posts, isEditable } = props
const [pinned, setPinned] = useState<JSX.Element[]>([])
const [open, setOpen] = useState(false)
const [editMode, setEditMode] = useState(false)
useEffect(() => {
async function getPinned() {
if (group.pinnedItems == null) {
updateGroup(group, { pinnedItems: [] })
} else {
const itemComponents = await Promise.all(
group.pinnedItems.map(async (element) => {
if (element.type === 'post') {
const post = await getPost(element.itemId)
if (post) {
return <PostCard post={post as Post} />
}
} else if (element.type === 'contract') {
const contract = await getContractFromId(element.itemId)
if (contract) {
return <ContractCard contract={contract as Contract} />
}
}
})
)
setPinned(
itemComponents.filter(
(element) => element != undefined
) as JSX.Element[]
)
}
}
getPinned()
}, [group, group.pinnedItems])
async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
await updateGroup(group, {
pinnedItems: [
...group.pinnedItems,
...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
],
})
setOpen(false)
}
return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? (
pinned.length > 0 || isEditable ? (
<div>
<Row className="mb-3 items-center justify-between">
<SectionHeader label={'Pinned'} />
{isEditable && (
<Button
color="gray"
size="xs"
onClick={() => {
setEditMode(!editMode)
}}
>
{editMode ? (
'Done'
) : (
<>
<PencilIcon className="inline h-4 w-4" />
Edit
</>
)}
</Button>
)}
</Row>
<div>
<Masonry
breakpointCols={{ default: 2, 768: 1 }}
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{pinned.length == 0 && !editMode && (
<div className="flex flex-col items-center justify-center">
<p className="text-center text-gray-400">
No pinned items yet. Click the edit button to add some!
</p>
</div>
)}
{pinned.map((element, index) => (
<div className="relative my-2">
{element}
{editMode && (
<CrossIcon
onClick={() => {
const newPinned = group.pinnedItems.filter((item) => {
return item.itemId !== group.pinnedItems[index].itemId
})
updateGroup(group, { pinnedItems: newPinned })
}}
/>
)}
</div>
))}
{editMode && group.pinnedItems && pinned.length < 6 && (
<div className=" py-2">
<Row
className={
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100'
}
>
<button
className="flex w-full justify-center"
onClick={() => setOpen(true)}
>
<PlusCircleIcon
className="h-12 w-12 text-gray-600"
aria-hidden="true"
/>
</button>
</Row>
</div>
)}
</Masonry>
</div>
<PinnedSelectModal
open={open}
group={group}
posts={posts}
setOpen={setOpen}
title="Pin a post or market"
description={
<div className={'text-md my-4 text-gray-600'}>
Pin posts or markets to the overview of this group.
</div>
}
onSubmit={onSubmit}
/>
</div>
) : (
<LoadingIndicator />
)
) : (
<></>
)
}
function SectionHeader(props: {
label: string
href?: string
children?: ReactNode
}) {
const { label, href, children } = props
const content = (
<>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</>
)
return (
<Row className="mb-3 items-center justify-between">
{href ? (
<SiteLink
className="text-xl"
href={href}
onClick={() => track('group click section header', { section: href })}
>
{content}
</SiteLink>
) : (
<span className="text-xl">{content}</span>
)}
{children}
</Row>
)
}
export function GroupAbout(props: {
group: Group
creator: User
user: User | null | undefined
isEditable: boolean
memberIds: string[]
}) {
const { group, creator, isEditable, user, memberIds } = props
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
}
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
function updateAnyoneCanJoin(newVal: boolean) {
if (group.anyoneCanJoin == newVal || !isEditable) return
setAnyoneCanJoin(newVal)
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
loading: 'Updating group...',
success: 'Updated group!',
error: "Couldn't update group",
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? memberIds.includes(user.id) : false
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
<Row className={'flex-wrap justify-between'}>
<div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</div>
{isEditable ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user && (
<Row>
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
</Row>
<div className={'block sm:hidden'}>
<Linkify text={group.about} />
</div>
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
<ChoicesToggleGroup
currentChoice={anyoneCanJoin.toString()}
choicesMap={anyoneCanJoinChoices}
setChoice={(choice) =>
updateAnyoneCanJoin(choice.toString() === 'true')
}
toggleClassName={'h-10'}
className={'ml-2'}
/>
) : (
<span className={'text-gray-700'}>
{anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
</Col>
</>
)
}
function CrossIcon(props: { onClick: () => void }) {
const { onClick } = props
return (
<div>
<button className=" text-gray-500 hover:text-gray-700" onClick={onClick}>
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-200 bg-opacity-50">
<XCircleIcon className="h-12 w-12 text-gray-600" />
</div>
</button>
</div>
)
}

View File

@ -32,27 +32,27 @@ export function GroupSelector(props: {
const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
.sort((a, b) => b.totalContracts - a.totalContracts)
// put the groups the user is a member of first
.sort((a, b) => {
if (memberGroupIds.includes(a.id)) {
return -1
}
if (memberGroupIds.includes(b.id)) {
return 1
}
return 0
})
const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name)
const sortGroups = (groups: Group[]) =>
groups.sort(
(a, b) =>
// weight group higher if user is a member
(memberGroupIds.includes(b.id) ? 5 : 1) * b.totalContracts -
(memberGroupIds.includes(a.id) ? 5 : 1) * a.totalContracts
)
const availableGroups = sortGroups(
openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
)
const filteredGroups = sortGroups(
availableGroups.filter((group) => searchInAny(query, group.name))
)
if (!showSelector || !creator) {

View File

@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react'
import { track } from '@amplitude/analytics-browser'
import { Col } from './col'
import { Tooltip } from 'web/components/tooltip'
import { Row } from 'web/components/layout/row'
type Tab = {
title: string
tabIcon?: ReactNode
content: ReactNode
// If set, show a badge with this content
badge?: string
stackedTabIcon?: ReactNode
inlineTabIcon?: ReactNode
tooltip?: string
}
type TabProps = {
@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)}
aria-current={activeIndex === i ? 'page' : undefined}
>
{tab.badge ? (
<span className="px-0.5 font-bold">{tab.badge}</span>
) : null}
<Col>
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>}
{tab.title}
<Tooltip text={tab.tooltip}>
{tab.stackedTabIcon && (
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
<Row className={'gap-1 '}>
{tab.title}
{tab.inlineTabIcon}
</Row>
</Tooltip>
</Col>
</a>
))}

View File

@ -182,7 +182,7 @@ export function OrderBookButton(props: {
size="xs"
color="blue"
>
Order book
Order book ({limitBets.length})
</Button>
<Modal open={open} setOpen={setOpen} size="lg">

View File

@ -2,8 +2,7 @@ import clsx from 'clsx'
import { Fragment } from 'react'
import { SiteLink } from './site-link'
// Return a JSX span, linkifying @username, #hashtags, and https://...
// TODO: Use a markdown parser instead of rolling our own here.
// Return a JSX span, linkifying @username, and https://...
export function Linkify(props: {
text: string
className?: string
@ -16,7 +15,7 @@ export function Linkify(props: {
// Find instances of @username, #hashtag, and https://...
const regex =
/(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/[-A-Za-z0-9+&@#/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#/%=~_|])/gi
/(?:^|\s)(?:@[a-z0-9_]+|https?:\/\/[-A-Za-z0-9+&@#/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#/%=~_|])/gi
const matches = text.match(regex) || []
const links = matches.map((match) => {
// Matches are in the form: " @username" or "https://example.com"
@ -26,7 +25,6 @@ export function Linkify(props: {
const href =
{
'@': `/${tag}`,
'#': `/tag/${tag}`,
}[symbol] ?? match.trim()
return (

View File

@ -20,7 +20,6 @@ import NotificationsIcon from 'web/components/notifications-icon'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { withTracking } from 'web/lib/service/analytics'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
@ -143,14 +142,12 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Tournaments', href: '/tournaments' },
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
{ name: 'Tournaments', href: '/tournaments' },
{ name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }
)
}
@ -158,20 +155,16 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Dating docs', href: '/date-docs' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
{
name: 'Sign out',
href: '#',
onClick: logout,
}
)
}
@ -220,15 +213,11 @@ function getMoreMobileNav() {
if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Dating docs', href: '/date-docs' },
],
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
signOut
)
}

View File

@ -10,6 +10,7 @@ import {
ChevronDownIcon,
ChevronUpIcon,
CurrencyDollarIcon,
ExclamationIcon,
InboxInIcon,
InformationCircleIcon,
LightBulbIcon,
@ -62,8 +63,9 @@ export function NotificationSettings(props: {
'tagged_user', // missing tagged on contract description email
'contract_from_followed_user',
'unique_bettors_on_your_contract',
'profit_loss_updates',
'opt_out_all',
// TODO: add these
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets
// 'referral_bonuses',
@ -116,7 +118,7 @@ export function NotificationSettings(props: {
const yourMarkets: SectionData = {
label: 'Markets You Created',
subscriptionTypes: [
'your_contract_closed',
// 'your_contract_closed',
'all_comments_on_my_markets',
'all_answers_on_my_markets',
'subsidized_your_market',
@ -153,23 +155,60 @@ export function NotificationSettings(props: {
'trending_markets',
'thank_you_for_purchases',
'onboarding_flow',
'profit_loss_updates',
],
}
const optOut: SectionData = {
label: 'Opt Out',
subscriptionTypes: ['opt_out_all'],
}
function NotificationSettingLine(props: {
description: string
subscriptionTypeKey: notification_preference
destinations: notification_destination_types[]
optOutAll: notification_destination_types[]
}) {
const { description, subscriptionTypeKey, destinations } = props
const { description, subscriptionTypeKey, destinations, optOutAll } = props
const previousInAppValue = destinations.includes('browser')
const previousEmailValue = destinations.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const [error, setError] = useState<string>('')
const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!'
const highlight = navigateToSection === subscriptionTypeKey
const attemptToChangeSetting = (
setting: 'browser' | 'email',
newValue: boolean
) => {
const necessaryError =
'This notification type is necessary. At least one destination must be enabled.'
const necessarySetting =
NOTIFICATION_DESCRIPTIONS[subscriptionTypeKey].necessary
if (
necessarySetting &&
setting === 'browser' &&
!emailEnabled &&
!newValue
) {
setError(necessaryError)
return
} else if (
necessarySetting &&
setting === 'email' &&
!inAppEnabled &&
!newValue
) {
setError(necessaryError)
return
}
changeSetting(setting, newValue)
}
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
toast
.promise(
@ -211,18 +250,21 @@ export function NotificationSettings(props: {
{!browserDisabled.includes(subscriptionTypeKey) && (
<SwitchSetting
checked={inAppEnabled}
onChange={(newVal) => changeSetting('browser', newVal)}
onChange={(newVal) => attemptToChangeSetting('browser', newVal)}
label={'Web'}
disabled={optOutAll.includes('browser')}
/>
)}
{emailsEnabled.includes(subscriptionTypeKey) && (
<SwitchSetting
checked={emailEnabled}
onChange={(newVal) => changeSetting('email', newVal)}
onChange={(newVal) => attemptToChangeSetting('email', newVal)}
label={'Email'}
disabled={optOutAll.includes('email')}
/>
)}
</Row>
{error && <span className={'text-error'}>{error}</span>}
</Col>
</Row>
)
@ -282,6 +324,11 @@ export function NotificationSettings(props: {
subType as notification_preference
)}
description={NOTIFICATION_DESCRIPTIONS[subType].simple}
optOutAll={
subType === 'opt_out_all' || subType === 'your_contract_closed'
? []
: getUsersSavedPreference('opt_out_all')
}
/>
))}
</Col>
@ -331,6 +378,10 @@ export function NotificationSettings(props: {
icon={<InboxInIcon className={'h-6 w-6'} />}
data={generalOther}
/>
<Section
icon={<ExclamationIcon className={'h-6 w-6'} />}
data={optOut}
/>
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col>
</div>

View File

@ -128,8 +128,10 @@ export function NumericResolutionPanel(props: {
<ResolveConfirmationButton
onResolve={resolve}
isSubmitting={isSubmitting}
openModalButtonClass={clsx('w-full mt-2', submitButtonClass)}
openModalButtonClass={clsx('w-full mt-2')}
submitButtonClass={submitButtonClass}
color={outcomeMode === 'CANCEL' ? 'yellow' : 'indigo'}
disabled={outcomeMode === undefined}
/>
</Col>
)

View File

@ -1,7 +1,11 @@
import clsx from 'clsx'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import {
ChevronLeftIcon,
ChevronRightIcon,
ExclamationCircleIcon,
} from '@heroicons/react/solid'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
@ -171,13 +175,17 @@ function Page2() {
the play money you bet with. You can also turn it into a real donation
to charity, at a 100:1 ratio.
</p>
<Row className="bg-greyscale-1 border-greyscale-2 mt-4 gap-2 rounded border py-2 pl-2 pr-4 text-sm text-indigo-700">
<ExclamationCircleIcon className="h-5 w-5" />
Mana can not be traded in for real money.
</Row>
<div className="mt-8 font-semibold">Example</div>
<p className="mt-2">
When you donate <span className="font-semibold">M$1000</span> to
Givewell, Manifold sends them{' '}
<span className="font-semibold">$10 USD</span>.
</p>
<video loop autoPlay className="my-4 h-full w-full">
<video loop autoPlay className="z-0 h-full w-full">
<source src="/welcome/charity.mp4" type="video/mp4" />
Your browser does not support video
</video>

View File

@ -0,0 +1,164 @@
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useState } from 'react'
import { PostCardList } from 'web/pages/group/[...slugs]'
import { Button } from './button'
import { PillButton } from './buttons/pill-button'
import { ContractSearch } from './contract-search'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function PinnedSelectModal(props: {
title: string
description?: React.ReactNode
open: boolean
setOpen: (open: boolean) => void
onSubmit: (
selectedItems: { itemId: string; type: string }[]
) => void | Promise<void>
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
group: Group
posts: Post[]
}) {
const {
title,
description,
open,
setOpen,
onSubmit,
contractSearchOptions,
posts,
group,
} = props
const [selectedItem, setSelectedItem] = useState<{
itemId: string
type: string
} | null>(null)
const [loading, setLoading] = useState(false)
const [selectedTab, setSelectedTab] = useState<'contracts' | 'posts'>('posts')
async function selectContract(contract: Contract) {
selectItem(contract.id, 'contract')
}
async function selectPost(post: Post) {
selectItem(post.id, 'post')
}
async function selectItem(itemId: string, type: string) {
setSelectedItem({ itemId: itemId, type: type })
}
async function onFinish() {
setLoading(true)
if (selectedItem) {
await onSubmit([
{
itemId: selectedItem.itemId,
type: selectedItem.type,
},
])
setLoading(false)
setOpen(false)
setSelectedItem(null)
}
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<div className="p-8 pb-0">
<Row>
<div className={'text-xl text-indigo-700'}>{title}</div>
{!loading && (
<Row className="grow justify-end gap-4">
{selectedItem && (
<Button onClick={onFinish} color="indigo">
Add to Pinned
</Button>
)}
<Button
onClick={() => {
setSelectedItem(null)
setOpen(false)
}}
color="gray"
>
Cancel
</Button>
</Row>
)}
</Row>
{description}
</div>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div>
<Row className="justify-center gap-4">
<PillButton
onSelect={() => setSelectedTab('contracts')}
selected={selectedTab === 'contracts'}
>
Contracts
</PillButton>
<PillButton
onSelect={() => setSelectedTab('posts')}
selected={selectedTab === 'posts'}
>
Posts
</PillButton>
</Row>
</div>
{selectedTab === 'contracts' ? (
<div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={selectContract}
cardUIOptions={{
hideGroupLink: true,
hideQuickBet: true,
noLinkAvatar: true,
}}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
</div>
) : (
<>
<div className="mt-2 px-2">
<PostCardList
posts={posts}
onPostClick={selectPost}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
/>
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</>
)}
</Col>
</Modal>
)
}

View File

@ -3,7 +3,7 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => (
<InfoBox
title="Play-money trading"
className="mt-4 max-w-md"
className="mt-4"
text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/>
)

View File

@ -1,155 +1,84 @@
import { ResponsiveLine } from '@nivo/line'
import { PortfolioMetrics } from 'common/user'
import { filterDefined } from 'common/util/array'
import { formatMoney } from 'common/util/format'
import { useMemo } from 'react'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { min, max } from 'lodash'
import dayjs from 'dayjs'
import { last } from 'lodash'
import { memo } from 'react'
import { useWindowSize } from 'web/hooks/use-window-size'
import { PortfolioMetrics } from 'common/user'
import { Col } from '../layout/col'
import { TooltipProps } from 'web/components/charts/helpers'
import {
HistoryPoint,
SingleValueHistoryChart,
} from 'web/components/charts/generic-charts'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit'
handleGraphDisplayChange: (arg0: string | number | null) => void
height?: number
}) {
const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
const { width } = useWindowSize()
const MARGIN = { top: 20, right: 10, bottom: 20, left: 70 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const valuePoints = getPoints('value', portfolioHistory)
const posProfitPoints = getPoints('posProfit', portfolioHistory)
const negProfitPoints = getPoints('negProfit', portfolioHistory)
export type GraphMode = 'profit' | 'value'
const valuePointsY = valuePoints.map((p) => p.y)
const posProfitPointsY = posProfitPoints.map((p) => p.y)
const negProfitPointsY = negProfitPoints.map((p) => p.y)
export const PortfolioTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { mouseX, xScale } = props
const d = dayjs(xScale.invert(mouseX))
return (
<Col className="text-xs font-semibold sm:text-sm">
<div>{d.format('MMM/D/YY')}</div>
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
{d.format('h:mm A')}
</div>
</Col>
)
}
let data
const getY = (mode: GraphMode, p: PortfolioMetrics) =>
p.balance + p.investmentValue - (mode === 'profit' ? p.totalDeposits : 0)
if (mode === 'value') {
data = [{ id: 'value', data: valuePoints, color: '#4f46e5' }]
} else {
data = [
{
id: 'negProfit',
data: negProfitPoints,
color: '#dc2626',
},
{
id: 'posProfit',
data: posProfitPoints,
color: '#14b8a6',
},
]
}
const numYTickValues = 2
const endDate = last(data[0].data)?.x
export function getPoints(mode: GraphMode, history: PortfolioMetrics[]) {
return history.map((p) => ({
x: new Date(p.timestamp),
y: getY(mode, p),
obj: p,
}))
}
const yMin =
mode === 'value'
? Math.min(...filterDefined(valuePointsY))
: Math.min(
...filterDefined(negProfitPointsY),
...filterDefined(posProfitPointsY)
)
const yMax =
mode === 'value'
? Math.max(...filterDefined(valuePointsY))
: Math.max(
...filterDefined(negProfitPointsY),
...filterDefined(posProfitPointsY)
)
export const PortfolioGraph = (props: {
mode: 'profit' | 'value'
history: PortfolioMetrics[]
width: number
height: number
onMouseOver?: (p: HistoryPoint<PortfolioMetrics> | undefined) => void
}) => {
const { mode, history, onMouseOver, width, height } = props
const { data, minDate, maxDate, minValue, maxValue } = useMemo(() => {
const data = getPoints(mode, history)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minDate = min(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxDate = max(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minValue = min(data.map((d) => d.y))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxValue = max(data.map((d) => d.y))!
return { data, minDate, maxDate, minValue, maxValue }
}, [mode, history])
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 200 : 100) }}
onMouseLeave={() => handleGraphDisplayChange(null)}
>
<ResponsiveLine
margin={{ top: 10, right: 0, left: 40, bottom: 10 }}
data={data}
xScale={{
type: 'time',
min: valuePoints[0]?.x,
max: endDate,
}}
yScale={{
type: 'linear',
stacked: false,
min: yMin,
max: yMax,
}}
curve="stepAfter"
enablePoints={false}
colors={{ datum: 'color' }}
axisBottom={{
tickValues: 0,
}}
pointBorderColor="#fff"
pointSize={valuePoints.length > 100 ? 0 : 6}
axisLeft={{
tickValues: numYTickValues,
format: '.3s',
}}
enableGridX={false}
enableGridY={true}
gridYValues={numYTickValues}
enableSlices="x"
animate={false}
yFormat={(value) => formatMoney(+value)}
enableArea={true}
areaOpacity={0.1}
sliceTooltip={({ slice }) => {
handleGraphDisplayChange(slice.points[0].data.yFormatted)
return (
<div className="rounded bg-white px-4 py-2 opacity-80">
<div
key={slice.points[0].id}
className="text-xs font-semibold sm:text-sm"
>
<Col>
<div>
{dayjs(slice.points[0].data.xFormatted).format('MMM/D/YY')}
</div>
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
{dayjs(slice.points[0].data.xFormatted).format('h:mm A')}
</div>
</Col>
</div>
{/* ))} */}
</div>
)
}}
></ResponsiveLine>
</div>
<SingleValueHistoryChart
w={width}
h={height}
margin={MARGIN}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([minValue, maxValue], [height - MARGIN_Y, 0])}
yKind="m$"
data={data}
curve={curveStepAfter}
Tooltip={PortfolioTooltip}
onMouseOver={onMouseOver}
color={
mode === 'value'
? '#4f46e5'
: (p: HistoryPoint) => (p.y >= 0 ? '#14b8a6' : '#f00')
}
/>
)
})
export function getPoints(
line: 'value' | 'posProfit' | 'negProfit',
portfolioHistory: PortfolioMetrics[]
) {
const points = portfolioHistory.map((p) => {
const { timestamp, balance, investmentValue, totalDeposits } = p
const value = balance + investmentValue
const profit = value - totalDeposits
let posProfit = null
let negProfit = null
if (profit < 0) {
negProfit = profit
} else {
posProfit = profit
}
return {
x: new Date(timestamp),
y:
line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit,
}
})
return points
}

View File

@ -1,35 +1,28 @@
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { last } from 'lodash'
import { memo, useRef, useState } from 'react'
import { memo, useState } from 'react'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users'
import { PillButton } from '../buttons/pill-button'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { PortfolioValueGraph } from './portfolio-value-graph'
import { GraphMode, PortfolioGraph } from './portfolio-value-graph'
import { SizedContainer } from 'web/components/sized-container'
export const PortfolioValueSection = memo(
function PortfolioValueSection(props: { userId: string }) {
const { userId } = props
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value')
const portfolioHistory = usePortfolioHistory(userId, 'allTime')
const [graphMode, setGraphMode] = useState<GraphMode>('profit')
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
number | string | null
>(null)
const handleGraphDisplayChange = (num: string | number | null) => {
setGraphDisplayNumber(num)
const handleGraphDisplayChange = (p: { y: number } | undefined) => {
setGraphDisplayNumber(p != null ? formatMoney(p.y) : null)
}
// Remember the last defined portfolio history.
const portfolioRef = useRef(portfolioHistory)
if (portfolioHistory) portfolioRef.current = portfolioHistory
const currPortfolioHistory = portfolioRef.current
const lastPortfolioMetrics = last(currPortfolioHistory)
if (!currPortfolioHistory || !lastPortfolioMetrics) {
const lastPortfolioMetrics = last(portfolioHistory)
if (!portfolioHistory || !lastPortfolioMetrics) {
return <></>
}
@ -40,24 +33,6 @@ export const PortfolioValueSection = memo(
<>
<Row className="mb-2 justify-between">
<Row className="gap-4 sm:gap-8">
<Col
className={clsx(
'cursor-pointer',
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
)}
onClick={() => setGraphMode('value')}
>
<div className="text-greyscale-6 text-xs sm:text-sm">
Portfolio value
</div>
<div className={clsx('text-lg text-indigo-600 sm:text-xl')}>
{graphMode === 'value'
? graphDisplayNumber
? graphDisplayNumber
: formatMoney(totalValue)
: formatMoney(totalValue)}
</div>
</Col>
<Col
className={clsx(
'cursor-pointer',
@ -65,7 +40,10 @@ export const PortfolioValueSection = memo(
? 'cursor-pointer opacity-40 hover:opacity-80'
: ''
)}
onClick={() => setGraphMode('profit')}
onClick={() => {
setGraphMode('profit')
setGraphDisplayNumber(null)
}}
>
<div className="text-greyscale-6 text-xs sm:text-sm">Profit</div>
<div
@ -91,89 +69,42 @@ export const PortfolioValueSection = memo(
: formatMoney(totalProfit)}
</div>
</Col>
<Col
className={clsx(
'cursor-pointer',
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
)}
onClick={() => {
setGraphMode('value')
setGraphDisplayNumber(null)
}}
>
<div className="text-greyscale-6 text-xs sm:text-sm">
Portfolio value
</div>
<div className={clsx('text-lg text-indigo-600 sm:text-xl')}>
{graphMode === 'value'
? graphDisplayNumber
? graphDisplayNumber
: formatMoney(totalValue)
: formatMoney(totalValue)}
</div>
</Col>
</Row>
</Row>
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
mode={graphMode}
handleGraphDisplayChange={handleGraphDisplayChange}
/>
<PortfolioPeriodSelection
portfolioPeriod={portfolioPeriod}
setPortfolioPeriod={setPortfolioPeriod}
className="border-greyscale-2 mt-2 gap-4 border-b"
selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
/>
<SizedContainer fullHeight={200} mobileHeight={100}>
{(width, height) => (
<PortfolioGraph
mode={graphMode}
history={portfolioHistory}
width={width}
height={height}
onMouseOver={handleGraphDisplayChange}
/>
)}
</SizedContainer>
</>
)
}
)
export function PortfolioPeriodSelection(props: {
setPortfolioPeriod: (string: any) => void
portfolioPeriod: string
className?: string
selectClassName?: string
}) {
const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } =
props
return (
<Row className={clsx(className, 'text-greyscale-4')}>
<button
className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('daily' as Period)}
>
1D
</button>
<button
className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('weekly' as Period)}
>
1W
</button>
<button
className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('monthly' as Period)}
>
1M
</button>
<button
className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('allTime' as Period)}
>
ALL
</button>
</Row>
)
}
export function GraphToggle(props: {
setGraphMode: (mode: 'profit' | 'value') => void
graphMode: string
}) {
const { setGraphMode, graphMode } = props
return (
<Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton
selected={graphMode === 'value'}
onSelect={() => {
setGraphMode('value')
}}
xs={true}
className="z-50"
>
Value
</PillButton>
<PillButton
selected={graphMode === 'profit'}
onSelect={() => {
setGraphMode('profit')
}}
xs={true}
className="z-50"
>
Profit
</PillButton>
</Row>
)
}

View File

@ -0,0 +1,82 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { Post } from 'common/post'
import Link from 'next/link'
import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar'
import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link'
export function PostCard(props: {
post: Post
onPostClick?: (post: Post) => void
highlightOptions?: CardHighlightOptions
}) {
const { post, onPostClick, highlightOptions } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
if (!user) return <> </>
return (
<div className="relative py-1">
<Row
className={clsx(
' relative gap-3 rounded-lg bg-white py-2 shadow-md hover:cursor-pointer hover:bg-gray-100',
itemIds?.includes(post.id) && highlightClassName
)}
>
<div className="flex-shrink-0">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className="">
<div className="text-sm text-gray-500">
<UserLink
className="text-neutral"
name={user?.name}
username={user?.username}
/>
<span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div>
<div className="text-lg font-medium text-gray-900">{post.title}</div>
</div>
</Row>
{onPostClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
onPostClick(post)
}}
/>
) : (
<Link href={postPath(post.slug)}>
<a
onClick={() => {
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
}}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</div>
)
}

View File

@ -8,12 +8,12 @@ export function ProbabilitySelector(props: {
const { probabilityInt, setProbabilityInt, isSubmitting } = props
return (
<Row className="items-center gap-2">
<label className="input-group input-group-lg w-fit text-lg">
<Row className="items-center gap-2">
<label className="input-group input-group-lg text-lg">
<input
type="number"
value={probabilityInt}
className="input input-bordered input-md text-lg"
className="input input-bordered input-md w-28 text-lg"
disabled={isSubmitting}
min={1}
max={99}
@ -23,14 +23,6 @@ export function ProbabilitySelector(props: {
/>
<span>%</span>
</label>
<input
type="range"
className="range range-primary"
min={1}
max={99}
value={probabilityInt}
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
/>
</Row>
)
}

View File

@ -11,6 +11,8 @@ import { ProbabilitySelector } from './probability-selector'
import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract'
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
import { Row } from 'web/components/layout/row'
import { capitalize } from 'lodash'
export function ResolutionPanel(props: {
isAdmin: boolean
@ -57,17 +59,6 @@ export function ResolutionPanel(props: {
setIsSubmitting(false)
}
const submitButtonClass =
outcome === 'YES'
? 'btn-primary'
: outcome === 'NO'
? 'bg-red-400 hover:bg-red-500'
: outcome === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: outcome === 'MKT'
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return (
<Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}>
{isAdmin && !isCreator && (
@ -76,18 +67,14 @@ export function ResolutionPanel(props: {
</span>
)}
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
<div className="mb-3 text-sm text-gray-500">Outcome</div>
<YesNoCancelSelector
className="mx-auto my-2"
selected={outcome}
onSelect={setOutcome}
btnClassName={isSubmitting ? 'btn-disabled' : ''}
/>
<Spacer h={4} />
<div>
{outcome === 'YES' ? (
<>
@ -109,9 +96,10 @@ export function ResolutionPanel(props: {
withdrawn from your account
</>
) : outcome === 'MKT' ? (
<Col className="gap-6">
<Col className="items-center gap-6">
<div>
{PAST_BETS} will be paid out at the probability you specify:
{capitalize(PAST_BETS)} will be paid out at the probability you
specify:
</div>
<ProbabilitySelector
probabilityInt={Math.round(prob)}
@ -123,17 +111,26 @@ export function ResolutionPanel(props: {
<>Resolving this market will immediately pay out {BETTORS}.</>
)}
</div>
<Spacer h={4} />
{!!error && <div className="text-red-500">{error}</div>}
<ResolveConfirmationButton
onResolve={resolve}
isSubmitting={isSubmitting}
openModalButtonClass={clsx('w-full mt-2', submitButtonClass)}
submitButtonClass={submitButtonClass}
/>
<Row className={'justify-center'}>
<ResolveConfirmationButton
color={
outcome === 'YES'
? 'green'
: outcome === 'NO'
? 'red'
: outcome === 'CANCEL'
? 'yellow'
: outcome === 'MKT'
? 'blue'
: 'indigo'
}
disabled={!outcome}
onResolve={resolve}
isSubmitting={isSubmitting}
/>
</Row>
</Col>
)
}

View File

@ -0,0 +1,47 @@
import { ArrowUpIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Row } from './layout/row'
export function ScrollToTopButton(props: { className?: string }) {
const { className } = props
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => {
if (window.scrollY > 500) {
setVisible(true)
} else {
setVisible(false)
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [])
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
return (
<button
className={clsx(
'border-greyscale-2 bg-greyscale-1 hover:bg-greyscale-2 rounded-full border py-2 pr-3 pl-2 text-sm transition-colors',
visible ? 'inline' : 'hidden',
className
)}
onClick={scrollToTop}
>
<Row className="text-greyscale-6 gap-2 align-middle">
<ArrowUpIcon className="text-greyscale-4 h-5 w-5" />
Scroll to top
</Row>
</button>
)
}

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