Merge branch 'main' into creator-reputation

This commit is contained in:
Pico2x 2022-10-03 10:08:20 +01:00
commit cef8048525
133 changed files with 5651 additions and 1814 deletions

View File

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

View File

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

View File

@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT =
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment // this is Phil's deployment
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
sprigEnvironmentId: 'Tu7kRZPm7daP',
} }

View File

@ -3,6 +3,7 @@ export type EnvConfig = {
firebaseConfig: FirebaseConfig firebaseConfig: FirebaseConfig
amplitudeApiKey?: string amplitudeApiKey?: string
twitchBotEndpoint?: string twitchBotEndpoint?: string
sprigEnvironmentId?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and // IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -40,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
@ -56,6 +58,7 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = { export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets', domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
sprigEnvironmentId: 'sQcrq9TDqkib',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',

View File

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

View File

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

View File

@ -116,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching", detailed: "Only answers by market creator on markets you're watching",
}, },
betting_streaks: { betting_streaks: {
simple: 'For predictions made over consecutive days', simple: `For prediction streaks`,
detailed: 'Bonuses for predictions made over consecutive days', detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
}, },
comments_by_followed_users_on_watched_markets: { comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow', simple: 'Only comments by users you follow',
@ -159,8 +159,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch', detailed: 'Large changes in probability on markets that you watch',
}, },
profit_loss_updates: { profit_loss_updates: {
simple: 'Weekly profit and loss updates', simple: 'Weekly portfolio updates',
detailed: 'Weekly profit and loss updates', detailed: 'Weekly portfolio updates',
}, },
referral_bonuses: { referral_bonuses: {
simple: 'For referring new users', simple: 'For referring new users',

View File

@ -9,4 +9,11 @@ export type Post = {
slug: string slug: string
} }
export type DateDoc = Post & {
bounty: number
birthday: number
type: 'date-doc'
contractSlug: string
}
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480

View File

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

View File

@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
}) })
export function formatMoney(amount: number) { 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
Math.floor(amount + 0.00000000001 * Math.sign(amount))
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }

View File

@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) { export function getUrl(text: string) {
@ -103,6 +104,7 @@ export const exhibitExts = [
Mention, Mention,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler,
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {

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: 200, // 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

@ -55,6 +55,7 @@ Returns the authenticated user.
Gets all groups, in no particular order. Gets all groups, in no particular order.
Parameters: Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can - `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned. join and groups they've already joined will be returned.
@ -64,24 +65,23 @@ Requires no authorization.
Gets a group by its slug. Gets a group by its slug.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL. Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]` ### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID. Gets a group by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL. Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets` ### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID. Gets a group's markets by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL. Note: group is singular in the URL.
### `GET /v0/markets` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.
@ -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 // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
url: string 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 mechanism: string // dpm-2 or cpmm-1
probability: number 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. 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 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 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 volume: number
volume7Days: number volume7Days: number
@ -408,7 +411,7 @@ Requires no authorization.
type FullMarket = LiteMarket & { type FullMarket = LiteMarket & {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers?: Answer[] answers?: Answer[] // dpm-2 markets only
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds textDescription: string // string description without formatting, images, or embeds
} }
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
Parameters: 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. - `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules 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). - 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. - `min`: The minimum value that the market may resolve to.
- `max`: The maximum 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: Example request:
@ -582,12 +591,17 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}' "initialProb":25}'
``` ```
### `POST /v0/market/[marketId]/add-liquidity`
Adds a specified amount of liquidity into the market.
- `amount`: Required. The amount of liquidity to add, in M$.
### `POST /v0/market/[marketId]/close` ### `POST /v0/market/[marketId]/close`
Closes a market on behalf of the authorized user. Closes a market on behalf of the authorized user.
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
### `POST /v0/market/[marketId]/resolve` ### `POST /v0/market/[marketId]/resolve`
@ -600,15 +614,18 @@ For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution. - `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. - `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: For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to. - `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: Example request:
@ -752,6 +769,7 @@ Requires no authorization.
## Changelog ## 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-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint - 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints - 2022-06-05: Add new authorized write endpoints

View File

@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {

View File

@ -14,7 +14,7 @@ import {
export { APIError } from '../../common/api' export { APIError } from '../../common/api'
type Output = Record<string, unknown> type Output = Record<string, unknown>
type AuthedUser = { export type AuthedUser = {
uid: string uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
} }

View File

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

View File

@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser, getContract, isProd } from './utils' import { chargeUser, getContract, isProd } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api' import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2), answers: z.string().trim().min(1).array().min(2),
}) })
export const createmarket = newEndpoint({}, async (req, auth) => { export const createmarket = newEndpoint({}, (req, auth) => {
return createMarketHelper(req.body, auth)
})
export async function createMarketHelper(body: any, auth: AuthedUser) {
const { const {
question, question,
description, description,
@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
outcomeType, outcomeType,
groupId, groupId,
visibility = 'public', visibility = 'public',
} = validate(bodySchema, req.body) } = validate(bodySchema, body)
let min, max, initialProb, isLogScale, answers let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
;({ min, max, initialValue, isLogScale } = validate( ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max) if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.') throw new APIError(400, 'Invalid range.')
@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') { if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body)) ;({ answers } = validate(multipleChoiceSchema, body))
} }
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
// convert string descriptions into JSONContent // convert string descriptions into JSONContent
const newDescription = const newDescription =
typeof description === 'string' !description || typeof description === 'string'
? { ? {
type: 'doc', type: 'doc',
content: [ content: [
{ {
type: 'paragraph', type: 'paragraph',
content: [{ type: 'text', text: description }], content: [{ type: 'text', text: description || ' ' }],
}, },
], ],
} }
: description ?? {} : description
const contract = getNewContract( const contract = getNewContract(
contractRef.id, contractRef.id,
@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
return contract return contract
}) }
const getSlug = async (question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)

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

@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { createMarketHelper } from './create-market'
import { DAY_MS } from '../../common/util/time'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() => const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -35,11 +38,20 @@ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(), groupId: z.string().optional(),
// Date doc fields:
bounty: z.number().optional(),
birthday: z.number().optional(),
type: z.string().optional(),
question: z.string().optional(),
}) })
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, content, groupId } = validate(postSchema, req.body) const { title, content, groupId, question, ...otherProps } = validate(
postSchema,
req.body
)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -51,14 +63,36 @@ export const createpost = newEndpoint({}, async (req, auth) => {
const postRef = firestore.collection('posts').doc() const postRef = firestore.collection('posts').doc()
const post: Post = { // If this is a date doc, create a market for it.
let contractSlug
if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
}
const post: Post = removeUndefinedProps({
...otherProps,
id: postRef.id, id: postRef.id,
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
} contractSlug,
})
await postRef.create(post) await postRef.create(post)
if (groupId) { if (groupId) {

View File

@ -320,7 +320,7 @@
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;" style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y"> data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> <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> </span>
</p> </p>
</div> </div>

View File

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

View File

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

View File

@ -1,44 +1,118 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues, log } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Txn } from '../../common/txn'
import { partition, sortBy } from 'lodash'
import { runTxn, TxnData } from './transact'
import * as admin from 'firebase-admin'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
.onUpdate(async (change, context) => { .onUpdate(async (change, context) => {
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
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
if ( if (
previousValue.closeTime !== contract.closeTime || !previousContract.isResolved &&
previousValue.question !== contract.question contract.isResolved &&
(openCommentBounties ?? 0) > 0
) { ) {
let sourceText = '' await handleUnusedCommentBountyRefunds(contract)
if ( // No need to notify users of resolution, that's handled in resolve-market
previousValue.closeTime !== contract.closeTime && return
contract.closeTime }
) { if (
sourceText = contract.closeTime.toString() previousContract.closeTime !== closeTime ||
} else if (previousValue.question !== contract.question) { previousContract.question !== question
sourceText = contract.question ) {
} await handleUpdatedCloseTime(previousContract, contract, eventId)
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
contract
)
} }
}) })
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
)
}
async function handleUnusedCommentBountyRefunds(contract: Contract) {
const outstandingCommentBounties = await getValues<Txn>(
firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY')
)
const commentBountiesOnThisContract = sortBy(
outstandingCommentBounties.filter(
(bounty) => bounty.data?.contractId === contract.id
),
(bounty) => bounty.createdTime
)
const [toBank, fromBank] = partition(
commentBountiesOnThisContract,
(bounty) => bounty.toType === 'BANK'
)
if (toBank.length <= fromBank.length) return
await firestore
.collection('contracts')
.doc(contract.id)
.update({ openCommentBounties: 0 })
const refunds = toBank.slice(fromBank.length)
await Promise.all(
refunds.map(async (extraBountyTxn) => {
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: extraBountyTxn.toId,
fromType: 'BANK',
toId: extraBountyTxn.fromId,
toType: 'USER',
amount: extraBountyTxn.amount,
token: 'M$',
category: 'REFUND_COMMENT_BOUNTY',
data: {
contractId: contract.id,
},
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(
`Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`,
result.status
)
log('message:', result.message)
} else {
log(
`Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`,
result.txn?.id
)
}
})
)
}
const firestore = admin.firestore()

View File

@ -0,0 +1,52 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'http://localhost:3000'
// Dev API key for Cause Exploration Prizes (@CEP)
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
// DEV API key for Criticism and Red Teaming (@CARTBot)
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
// Warning: Checking these in can be dangerous!
// Prod API key for @CEPBot
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`${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(`${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
async function addLiquidityById(id: string, amount: number) {
const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
amount: amount,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug('cart-contest')
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
// Resolve each market to NO
for (const market of markets.slice(0, 3)) {
console.log(market.slug, market.totalLiquidity)
const resp = await addLiquidityById(market.id, 200)
console.log(resp)
}
}
main()

View File

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

View File

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

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, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' 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() const firestore = admin.firestore()
@ -27,28 +28,46 @@ export const updateMetrics = functions
.onRun(updateMetricsCore) .onRun(updateMetricsCore)
export async function updateMetricsCore() { export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories, groups] = console.log('Loading users')
await Promise.all([ const users = await getValues<User>(firestore.collection('users'))
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 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( const contractsByGroup = await Promise.all(
groups.map((group) => { groups.map((group) =>
return getValues( getValues(
firestore firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.collection('groupContracts') .collection('groupContracts')
) )
}) )
) )
log( log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Row } from './layout/row' import { Row } from './layout/row'
export function AmountInput(props: { export function AmountInput(props: {
@ -36,21 +35,18 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return ( return (
<> <>
<Col className={className}> <Col className={className}>
<label className="font-sm md:font-lg"> <label className="font-sm md:font-lg relative">
<span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<input <input
className={clsx( className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error', error && 'input-error',
isMobile ? 'w-24' : '', 'w-24 md:w-auto',
inputClassName inputClassName
)} )}
ref={inputRef} ref={inputRef}
@ -59,7 +55,6 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
@ -162,7 +157,7 @@ export function BuyAmountInput(props: {
max="205" max="205"
value={getRaw(amount ?? 0)} value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))} 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" step="5"
/> />
)} )}

View File

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

View File

@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(false) 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 ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Row className="justify-between"> <Row className="justify-between">
@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: {
Clear Clear
</button> </button>
)} )}
<ResolveConfirmationButton <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} onResolve={onResolve}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={resolutionButtonClass}
submitButtonClass={resolutionButtonClass}
/> />
</Row> </Row>
</Col> </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 username?: string
avatarUrl?: string avatarUrl?: string
noLink?: boolean noLink?: boolean
size?: number | 'xs' | 'sm' size?: number | 'xxs' | 'xs' | 'sm'
className?: string className?: string
}) { }) {
const { username, noLink, size, className } = props const { username, noLink, size, className } = props
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s =
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4 const sizeInPx = s * 4
const onClick = 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 { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -85,7 +86,12 @@ export function BinaryMobileBetting(props: { contract: BinaryContract }) {
if (user) { if (user) {
return <SignedInBinaryMobileBetting contract={contract} user={user} /> return <SignedInBinaryMobileBetting contract={contract} user={user} />
} else { } 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 { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { useWindowSize } from 'web/hooks/use-window-size'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -179,12 +178,7 @@ export function BuyPanel(props: {
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const windowSize = useWindowSize() const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const initialOutcome =
windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(
initialOutcome
)
const [betAmount, setBetAmount] = useState<number | undefined>(10) const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -395,22 +389,16 @@ export function BuyPanel(props: {
<WarningConfirmationButton <WarningConfirmationButton
marketType="binary" marketType="binary"
amount={betAmount} amount={betAmount}
outcome={outcome}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={clsx( disabled={!!betDisabled || outcome === undefined}
'btn mb-2 flex-1', size="xl"
betDisabled color={outcome === 'NO' ? 'red' : 'green'}
? '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'
)}
/> />
)} )}
<button <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)} onClick={() => setSeeLimit(true)}
> >
Advanced Advanced
@ -419,7 +407,7 @@ export function BuyPanel(props: {
open={seeLimit} open={seeLimit}
setOpen={setSeeLimit} setOpen={setSeeLimit}
position="center" position="center"
className="rounded-lg bg-white px-4 pb-8" className="rounded-lg bg-white px-4 pb-4"
> >
<Title text="Limit Order" /> <Title text="Limit Order" />
<LimitOrderPanel <LimitOrderPanel
@ -428,6 +416,11 @@ export function BuyPanel(props: {
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
/> />
<LimitBets
contract={contract}
bets={unfilledBets as LimitBet[]}
className="mt-4"
/>
</Modal> </Modal>
</Col> </Col>
</Col> </Col>

View File

@ -0,0 +1,120 @@
import { sumBy } from 'lodash'
import clsx from 'clsx'
import { Bet } from 'web/lib/firebase/bets'
import { formatMoney, formatWithCommas } from 'common/util/format'
import { Col } from './layout/col'
import { Contract } from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { YesLabel, NoLabel } from './outcome-label'
import {
calculatePayout,
getContractBetMetrics,
getProbability,
} from 'common/calculate'
import { InfoTooltip } from './info-tooltip'
import { ProfitBadge } from './profit-badge'
export function BetsSummary(props: {
contract: Contract
userBets: Bet[]
className?: string
}) {
const { contract, className } = props
const { resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const bets = props.userBets.filter((b) => !b.isAnte)
const { profitPercent, payout, profit, invested } = getContractBetMetrics(
contract,
bets
)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const position = yesWinnings - noWinnings
const prob = isBinary ? getProbability(contract) : 0
const expectation = prob * yesWinnings + (1 - prob) * noWinnings
if (bets.length === 0) return <></>
return (
<Col className={clsx(className, 'gap-4')}>
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : isBinary ? (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Position{' '}
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." />
</div>
<div className="whitespace-nowrap">
{position > 1e-7 ? (
<>
<YesLabel /> {formatWithCommas(position)}
</>
) : position < -1e-7 ? (
<>
<NoLabel /> {formatWithCommas(-position)}
</>
) : (
'——'
)}
</div>
</Col>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expectation{''}
<InfoTooltip text="The estimated payout of your position using the current market probability." />
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
<Col className="hidden sm:inline">
<div className="whitespace-nowrap text-sm text-gray-500">
Invested{' '}
<InfoTooltip text="Cash currently invested in this market." />
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
{isBinary && !resolution && (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expectation{' '}
<InfoTooltip text="The estimated payout of your position using the current market probability." />
</div>
<div className="whitespace-nowrap">{formatMoney(expectation)}</div>
</Col>
)}
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Profit{' '}
<InfoTooltip text="Includes both realized & unrealized gains/losses." />
</div>
<div className="whitespace-nowrap">
{formatMoney(profit)}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
</Row>
</Col>
)
}

View File

@ -2,7 +2,6 @@ import Link from 'next/link'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
@ -22,7 +21,7 @@ import {
import { Row } from './layout/row' import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { OutcomeLabel } from './outcome-label'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { import {
@ -38,14 +37,19 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts' 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 BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' 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 return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = usePersistentState<BetSort>('newest', {
const [filter, setFilter] = useState<BetFilter>('all') 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 [page, setPage] = useState(0)
const start = page * CONTRACTS_PER_PAGE const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE
@ -337,8 +347,7 @@ function ContractBets(props: {
<BetsSummary <BetsSummary
className="mt-8 mr-5 flex-1 sm:mr-8" className="mt-8 mr-5 flex-1 sm:mr-8"
contract={contract} contract={contract}
bets={bets} userBets={bets}
isYourBets={isYourBets}
/> />
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
@ -364,125 +373,6 @@ function ContractBets(props: {
) )
} }
export function BetsSummary(props: {
contract: Contract
bets: Bet[]
isYourBets: boolean
className?: string
}) {
const { contract, isYourBets, className } = props
const { resolution, closeTime, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isCpmm = mechanism === 'cpmm-1'
const isClosed = closeTime && Date.now() > closeTime
const bets = props.bets.filter((b) => !b.isAnte)
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const [showSellModal, setShowSellModal] = useState(false)
const user = useUser()
const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0)
? floatingEqual(totalShares.NO ?? 0, 0)
? undefined
: 'NO'
: 'YES'
const canSell =
isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user
return (
<Col className={clsx(className, 'gap-4')}>
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
{canSell && (
<>
<button
className="btn btn-sm self-end"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</>
)}
</Row>
<Row className="flex-wrap-none gap-4">
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : isBinary ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel />
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <NoLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expected value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
</Row>
</Col>
)
}
export function ContractBetsTable(props: { export function ContractBetsTable(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
@ -719,8 +609,8 @@ function SellButton(props: {
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell', label: 'Sell',
disabled: isSubmitting,
}} }}
submitBtn={{ className: 'btn-primary', label: 'Sell' }} submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => { onSubmit={async () => {
@ -750,30 +640,3 @@ function SellButton(props: {
</ConfirmationButton> </ConfirmationButton>
) )
} }
export function ProfitBadge(props: {
profitPercent: number
round?: boolean
className?: string
}) {
const { profitPercent, round, className } = props
if (!profitPercent) return null
const colors =
profitPercent > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
return (
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors,
className
)}
>
{(profitPercent > 0 ? '+' : '') +
profitPercent.toFixed(round ? 0 : 1) +
'%'}
</span>
)
}

View File

@ -46,20 +46,26 @@ export function Button(props: {
<button <button
type={type} type={type}
className={clsx( 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, sizeClasses,
color === 'green' && 'btn-primary text-white', color === 'green' &&
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', 'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'red' &&
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', 'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'yellow' &&
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', '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' && 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' && 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' && color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none', 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
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,
MARGIN_X,
MARGIN_Y,
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 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}
xScale={xScale}
yScale={yScale}
data={data}
color="#11b981"
onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip}
pct
/>
)
}

View File

@ -0,0 +1,222 @@
import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
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,
MARGIN_X,
MARGIN_Y,
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 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}
xScale={xScale}
yScale={yScale}
data={data}
colors={CATEGORY_COLORS}
onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip}
pct
/>
)
}

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,59 @@
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, MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts'
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}
xScale={xScale}
yScale={yScale}
data={data}
color={NUMERIC_GRAPH_COLOR}
onMouseOver={onMouseOver}
Tooltip={NumericChartTooltip}
/>
)
}

View File

@ -0,0 +1,105 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
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,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
} from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// 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 ?? 0 - MARGIN_X])
// clamp log scale to make sure zeroes go to the bottom
const yScale = isLogScale
? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true)
: scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0])
return (
<SingleValueHistoryChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
onMouseOver={onMouseOver}
Tooltip={PseudoNumericChartTooltip}
color={NUMERIC_GRAPH_COLOR}
/>
)
}

View File

@ -0,0 +1,253 @@
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 {
curveLinear,
curveStepAfter,
stack,
stackOrderReverse,
SeriesPoint,
} from 'd3-shape'
import { range } from 'lodash'
import {
ContinuousScale,
SVGChart,
AreaPath,
AreaWithTopStroke,
Point,
TooltipComponent,
formatPct,
} from './helpers'
import { useEvent } from 'web/hooks/use-event'
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>
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
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => {
const { color, data, yScale, w, h, 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 onMouseOver = useEvent(betAtPointSelector(data, xScale))
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}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curveLinear}
/>
</SVGChart>
)
}
export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
data: P[]
w: number
h: number
colors: readonly string[]
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { colors, data, yScale, w, h, Tooltip, pct } = 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 pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const series = useMemo(() => {
const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, o) => y[o])
.order(stackOrderReverse)
return d3Stack(data)
}, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
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}
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={curveStepAfter}
fill={colors[i]}
/>
))}
</SVGChart>
)
}
export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[]
w: number
h: number
color: string
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { color, data, yScale, w, h, Tooltip, pct } = 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 pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
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}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curveStepAfter}
/>
</SVGChart>
)
}

View File

@ -0,0 +1,359 @@
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, curveStepAfter, 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 const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
export const MARGIN_X = MARGIN.right + MARGIN.left
export const MARGIN_Y = MARGIN.top + MARGIN.bottom
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
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)
.transition()
.duration(250)
.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 ?? curveStepAfter)
// 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 ?? curveStepAfter)
// 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: {
color: string
data: P[]
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve?: CurveFactory
}) => {
const { color, data, 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
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, 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_X
const innerH = h - MARGIN_Y
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}
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={MARGIN_XFORM}>
<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
className?: string
children: React.ReactNode
}) => {
const { setElem, pos, 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_STYLE, ...pos }}
>
{children}
</div>
)
}
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

@ -126,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}> <TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <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} disabled={!editor || editor.isEmpty}
onClick={submit} onClick={submit}
> >

View File

@ -1,5 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { Button, ColorType, SizeType } from './button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -9,6 +10,9 @@ export function ConfirmationButton(props: {
label: string label: string
icon?: JSX.Element icon?: JSX.Element
className?: string className?: string
color?: ColorType
size?: SizeType
disabled?: boolean
} }
cancelBtn?: { cancelBtn?: {
label?: string label?: string
@ -70,18 +74,22 @@ export function ConfirmationButton(props: {
</Row> </Row>
</Col> </Col>
</Modal> </Modal>
<div
className={openModalBtn.className} <Button
className={clsx(openModalBtn.className)}
onClick={() => { onClick={() => {
if (disabled) { if (disabled) {
return return
} }
updateOpen(true) updateOpen(true)
}} }}
disabled={openModalBtn.disabled}
color={openModalBtn.color}
size={openModalBtn.size}
> >
{openModalBtn.icon} {openModalBtn.icon}
{openModalBtn.label} {openModalBtn.label}
</div> </Button>
</> </>
) )
} }
@ -91,18 +99,25 @@ export function ResolveConfirmationButton(props: {
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClass?: string submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) { }) {
const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } = const {
props onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx( className: clsx('border-none self-start', openModalButtonClass),
'btn border-none self-start',
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: 'Resolve', label: 'Resolve',
color: color,
disabled: isSubmitting || disabled,
size: 'xl',
}} }}
cancelBtn={{ cancelBtn={{
label: 'Back', label: 'Back',

View File

@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import { import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { import {
@ -82,7 +79,7 @@ export function ContractSearch(props: {
defaultFilter?: filter defaultFilter?: filter
defaultPill?: string defaultPill?: string
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
cardUIOptions?: { cardUIOptions?: {

View File

@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true, noLinkAvatar: true,
}} }}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), itemIds: contracts.map((c) => c.id),
highlightClassName: highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!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 hideGroupLink?: boolean
trackingPostfix?: string trackingPostfix?: string
noLinkAvatar?: boolean noLinkAvatar?: boolean
newTab?: boolean
}) { }) {
const { const {
showTime, showTime,
@ -56,6 +57,7 @@ export function ContractCard(props: {
hideGroupLink, hideGroupLink,
trackingPostfix, trackingPostfix,
noLinkAvatar, noLinkAvatar,
newTab,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -189,6 +191,7 @@ export function ContractCard(props: {
} }
)} )}
className="absolute top-0 left-0 right-0 bottom-0" className="absolute top-0 left-0 right-0 bottom-0"
target={newTab ? '_blank' : '_self'}
/> />
</Link> </Link>
)} )}
@ -211,7 +214,9 @@ export function BinaryResolutionOrChance(props: {
const probChanged = before !== after const probChanged = before !== after
return ( return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> <Col
className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)}
>
{resolution ? ( {resolution ? (
<Row className="flex items-start"> <Row className="flex items-start">
<div> <div>

View File

@ -32,6 +32,10 @@ import { ExclamationIcon, PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle' import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile' 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' export type ShowTime = 'resolve-date' | 'close-date'
@ -63,6 +67,8 @@ export function MiscDetails(props: {
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : ( ) : (
@ -126,9 +132,10 @@ export function ContractDetails(props: {
</Row> </Row>
{/* GROUPS */} {/* GROUPS */}
{isMobile && ( {isMobile && (
<div className="mt-2"> <Row className="mt-2 gap-1">
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} /> <MarketGroups contract={contract} disabled={disabled} />
</div> </Row>
)} )}
</Col> </Col>
) )
@ -180,14 +187,18 @@ export function MarketSubheader(props: {
</Tooltip> </Tooltip>
)} )}
</Row> </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 <CloseOrResolveTime
contract={contract} contract={contract}
resolvedDate={resolvedDate} resolvedDate={resolvedDate}
isCreator={isCreator} isCreator={isCreator}
disabled={disabled}
/> />
{!isMobile && ( {!isMobile && (
<MarketGroups contract={contract} disabled={disabled} /> <Row className={'gap-1'}>
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} />
</Row>
)} )}
</Row> </Row>
</Col> </Col>
@ -199,8 +210,9 @@ export function CloseOrResolveTime(props: {
contract: Contract contract: Contract
resolvedDate: any resolvedDate: any
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { contract, resolvedDate, isCreator } = props const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) { if (!!closeTime || !!resolvedDate) {
return ( return (
@ -224,6 +236,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
isCreator={isCreator ?? false} isCreator={isCreator ?? false}
disabled={disabled}
/> />
</Row> </Row>
)} )}
@ -244,7 +257,8 @@ export function MarketGroups(props: {
return ( return (
<> <>
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} /> <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && ( {!disabled && user && (
<button <button
className="text-greyscale-4 hover:text-greyscale-3" className="text-greyscale-4 hover:text-greyscale-3"
@ -329,19 +343,34 @@ export function ExtraMobileContractDetails(props: {
) )
} }
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { export function GroupDisplay(props: {
const { groupToDisplay } = props groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
if (groupToDisplay) { 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)}> <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]"> {groupSection}
{groupToDisplay.name}
</a>
</Link> </Link>
) )
} else } else
return ( 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 No Group
</div> </div>
) )
@ -351,8 +380,9 @@ function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { closeTime, contract, isCreator } = props const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime) const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs() const dayJsNow = dayjs()
@ -365,18 +395,22 @@ function EditableCloseDate(props: {
closeTime && dayJsCloseTime.format('HH:mm') closeTime && dayJsCloseTime.format('HH:mm')
) )
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => { 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) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false) if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) { else {
const content = contract.description const content = contract.description
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
@ -425,13 +459,21 @@ function EditableCloseDate(props: {
/> />
</Row> </Row>
<Button <Button
className="mt-2" className="mt-4"
size={'xs'} size={'xs'}
color={'indigo'} color={'indigo'}
onClick={onSave} onClick={() => onSave()}
> >
Done Done
</Button> </Button>
<Button
className="mt-4"
size={'xs'}
color={'gray-white'}
onClick={() => onSave(Date.now())}
>
Close Now
</Button>
</Col> </Col>
</Modal> </Modal>
<DateTimeTooltip <DateTimeTooltip
@ -439,8 +481,8 @@ function EditableCloseDate(props: {
time={closeTime} time={closeTime}
> >
<span <span
className={isCreator ? 'cursor-pointer' : ''} className={!disabled && isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)} onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
> >
{isSameDay ? ( {isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span> <span className={'capitalize'}> {fromNow(closeTime)}</span>

View File

@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' 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 { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Title } from '../title' import { Title } from '../title'
@ -196,9 +196,7 @@ export function ContractInfoDialog(props: {
<Row className="flex-wrap"> <Row className="flex-wrap">
<DuplicateContractButton contract={contract} /> <DuplicateContractButton contract={contract} />
</Row> </Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {!contract.resolution && <LiquidityBountyPanel contract={contract} />}
<LiquidityPanel contract={contract} />
)}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

@ -1,8 +1,8 @@
import React from 'react' import React, { useEffect, useRef, useState } from 'react'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' 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 { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
@ -14,7 +14,6 @@ import {
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetButton, { BinaryMobileBetting } from '../bet-button' import BetButton, { BinaryMobileBetting } from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph'
import { import {
Contract, Contract,
CPMMContract, CPMMContract,
@ -25,7 +24,6 @@ import {
BinaryContract, BinaryContract,
} from 'common/contract' } from 'common/contract'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph'
import { ContractReportResolution } from './contract-report-resolution' import { ContractReportResolution } from './contract-report-resolution'
const OverviewQuestion = (props: { text: string }) => ( const OverviewQuestion = (props: { text: string }) => (
@ -46,8 +44,43 @@ const BetWidget = (props: { contract: CPMMContract }) => {
) )
} }
const NumericOverview = (props: { contract: NumericContract }) => { const SizedContractChart = (props: {
const { contract } = props contract: Contract
bets: Bet[]
fullHeight: number
mobileHeight: number
}) => {
const { contract, bets, fullHeight, mobileHeight } = props
const containerRef = useRef<HTMLDivElement>(null)
const [chartWidth, setChartWidth] = useState<number>()
const [chartHeight, setChartHeight] = useState<number>()
useEffect(() => {
const handleResize = () => {
setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight)
setChartWidth(containerRef.current?.clientWidth)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [fullHeight, mobileHeight])
return (
<div ref={containerRef}>
{chartWidth != null && chartHeight != null && (
<ContractChart
contract={contract}
bets={bets}
width={chartWidth}
height={chartHeight}
/>
)}
</div>
)
}
const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => {
const { contract, bets } = props
return ( return (
<Col className="gap-1 md:gap-2"> <Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4"> <Col className="gap-3 px-2 sm:gap-4">
@ -64,7 +97,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract} contract={contract}
/> />
</Col> </Col>
<NumericGraph contract={contract} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -87,7 +125,12 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
</Row> </Row>
</Row> </Row>
</Col> </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"> <Row className="items-center justify-between gap-4 xl:hidden">
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} /> <BinaryMobileBetting contract={contract} />
@ -118,9 +161,12 @@ const ChoiceOverview = (props: {
</Row> </Row>
)} )}
</Col> </Col>
<Col className={'mb-1 gap-y-2'}> <SizedContractChart
<AnswersGraph contract={contract} bets={[...bets].reverse()} /> contract={contract}
</Col> bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col> </Col>
) )
} }
@ -146,7 +192,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />} {tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row> </Row>
</Col> </Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -160,7 +211,7 @@ export const ContractOverview = (props: {
case 'BINARY': case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} /> return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC': case 'NUMERIC':
return <NumericOverview contract={contract} /> return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC': case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} /> return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE': 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

@ -5,11 +5,11 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -17,68 +17,66 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment'
export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { import { Button } from 'web/components/button'
const { contract, bets } = props 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'
const isMobile = useIsMobile() export function ContractTabs(props: {
const user = useUser() contract: Contract
const userBets = bets: Bet[]
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) userBets: Bet[]
comments: ContractComment[]
}) {
const { contract, bets, userBets, comments } = props
const yourTrades = ( const yourTrades = (
<div> <div>
<BetsSummary
className="px-2"
contract={contract}
bets={userBets ?? []}
isYourBets
/>
<Spacer h={6} /> <Spacer h={6} />
<ContractBetsTable contract={contract} bets={userBets ?? []} isYourBets /> <ContractBetsTable contract={contract} bets={userBets} isYourBets />
<Spacer h={12} /> <Spacer h={12} />
</div> </div>
) )
const tabs = buildArray(
{
title: 'Comments',
content: <CommentsTabContent contract={contract} comments={comments} />,
},
bets.length > 0 && {
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
userBets.length > 0 && {
title: 'Your trades',
content: yourTrades,
}
)
return ( return (
<Tabs <Tabs className="mb-4" currentPageForAnalytics={'contract'} tabs={tabs} />
className="mb-4"
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: <CommentsTabContent contract={contract} />,
},
{
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
...(!user || !userBets?.length
? []
: [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]}
/>
) )
} }
const CommentsTabContent = memo(function CommentsTabContent(props: { const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract contract: Contract
comments: ContractComment[]
}) { }) {
const { contract } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = useState<'Newest' | 'Best'>('Newest')
const me = useUser()
if (comments == null) { if (comments == null) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
@ -130,12 +128,51 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
</> </>
) )
} else { } else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_') const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
const commentsByParent = groupBy(
sortBy(comments, (c) =>
sort === 'Newest'
? -c.createdTime
: // 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
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
),
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
return ( return (
<> <>
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
{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>
)}
{topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}
contract={contract} contract={contract}

View File

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

View File

@ -18,9 +18,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
return ( return (
<Row> <Row>
<FollowMarketButton contract={contract} user={user} /> <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> <Tooltip text="Share" placement="bottom" noTap noFade>
<Button <Button
size="sm" size="sm"

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline' import React, { useMemo, useState } from 'react'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes' import { useUserLikes } from 'web/hooks/use-likes'
@ -8,74 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes' import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like' 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 { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash' import { sum } from 'lodash'
import { Tooltip } from '../tooltip' import { TipButton } from './tip-button'
export function LikeMarketButton(props: { export function LikeMarketButton(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
}) { }) {
const { contract, user } = props const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id const tips = useMarketTipTxns(contract.id)
)
const totalTipped = useMemo(() => { const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount)) return sum(tips.map((tip) => tip.amount))
}, [tips]) }, [tips])
const likes = useUserLikes(user?.id) const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes const userLikedContractIds = likes
?.filter((l) => l.type === 'contract') ?.filter((l) => l.type === 'contract')
.map((l) => l.id) .map((l) => l.id)
const onLike = async () => { const onLike = async () => {
if (!user) return firebaseLogin() 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)}!`) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
return ( return (
<Tooltip <TipButton
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`} onClick={onLike}
placement="bottom" tipAmount={LIKE_TIP_AMOUNT}
noTap totalTipped={totalTipped}
noFade userTipped={
> !!user &&
<Button (isLiking ||
size={'sm'} userLikedContractIds?.includes(contract.id) ||
className={'max-w-xs self-center'} (!likes && !!contract.likedByUserIds?.includes(user.id)))
color={'gray-white'} }
onClick={onLike} disabled={contract.creatorId === user?.id}
> />
<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>
) )
} }

View File

@ -1,27 +1,30 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CPMMContract } from 'common/contract' import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from './amount-input' import { AmountInput } from 'web/components/amount-input'
import { Row } from './layout/row' import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity' import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from './outcome-label' import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from './layout/col' import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics' 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 { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin' 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 { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser() 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) const [showWithdrawal, setShowWithdrawal] = useState(false)
@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
const isCreator = user?.id === contract.creatorId const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin() const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return ( return (
<Tabs <Tabs
tabs={buildArray( 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', title: 'Bounty Comments',
content: <ViewLiquidityPanel contract={contract} />, 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 clsx from 'clsx'
import { partition } from 'lodash'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract' import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format' import { formatPercent } from 'common/util/format'
@ -7,6 +7,7 @@ import { SiteLink } from '../site-link'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { useContractWithPreload } from 'web/hooks/use-contract'
export function ProbChangeTable(props: { export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined changes: CPMMContract[] | undefined
@ -16,16 +17,14 @@ export function ProbChangeTable(props: {
if (!changes) return <LoadingIndicator /> if (!changes) return <LoadingIndicator />
const [positiveChanges, negativeChanges] = partition( const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse()
changes, const ascendingChanges = sortBy(changes, (c) => c.probChanges.day)
(c) => c.probChanges.day > 0
)
const threshold = 0.01 const threshold = 0.01
const positiveAboveThreshold = positiveChanges.filter( const positiveAboveThreshold = descendingChanges.filter(
(c) => c.probChanges.day > threshold (c) => c.probChanges.day > threshold
) )
const negativeAboveThreshold = negativeChanges.filter( const negativeAboveThreshold = ascendingChanges.filter(
(c) => c.probChanges.day < threshold (c) => c.probChanges.day < threshold
) )
const maxRows = Math.min( const maxRows = Math.min(
@ -59,7 +58,9 @@ export function ProbChangeRow(props: {
contract: CPMMContract contract: CPMMContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { className } = props
const contract =
(useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
return ( return (
<Row <Row
className={clsx( 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 { import {
CheckIcon, CheckIcon,
CodeIcon, CodeIcon,
EyeOffIcon,
PhotographIcon, PhotographIcon,
PresentationChartLineIcon, PresentationChartLineIcon,
TrashIcon, TrashIcon,
@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon'
import ItalicIcon from 'web/lib/icons/italic-icon' import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon' import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse' import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -107,6 +109,9 @@ export function useTextEditor(props: {
}), }),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
], ],
content: defaultValue, content: defaultValue,
}) })
@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) {
const isBold = editor.isActive('bold') const isBold = editor.isActive('bold')
const isItalic = editor.isActive('italic') const isItalic = editor.isActive('italic')
const isLink = editor.isActive('link') const isLink = editor.isActive('link')
const isSpoiler = editor.isActive('spoiler')
const setLink = () => { const setLink = () => {
const href = url && getUrl(url) const href = url && getUrl(url)
@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) {
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}> <button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
</button> </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, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text',
spoilerCloseClass:
'rounded-sm bg-greyscale-6 text-greyscale-6 cursor-pointer select-none',
}),
], ],
content, content,
editable: false, editable: false,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,378 @@
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'
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 || pinned.length > 0 ? (
<>
<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}
/>
</>
) : (
<></>
)
}
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 openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id) const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.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) => const sortGroups = (groups: Group[]) =>
searchInAny(query, group.name) 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) { if (!showSelector || !creator) {

View File

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

View File

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

View File

@ -20,8 +20,6 @@ import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user' import { User } from 'common/user'
import { PAST_BETS } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
@ -42,7 +40,7 @@ const signedOutNavigation = [
export const userProfileItem = (user: User) => ({ export const userProfileItem = (user: User) => ({
name: formatMoney(user.balance), name: formatMoney(user.balance),
trackingEventName: 'profile', trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`, href: `/${user.username}?tab=portfolio`,
icon: () => ( icon: () => (
<Avatar <Avatar
className="mx-auto my-1" className="mx-auto my-1"

View File

@ -4,12 +4,11 @@ import { User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { PAST_BETS } from 'common/user'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=${PAST_BETS}`}> <Link href={`/${user.username}?tab=portfolio`}>
<a <a
onClick={trackCallback('sidebar: profile')} onClick={trackCallback('sidebar: profile')}
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

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

View File

@ -62,8 +62,8 @@ export function NotificationSettings(props: {
'tagged_user', // missing tagged on contract description email 'tagged_user', // missing tagged on contract description email
'contract_from_followed_user', 'contract_from_followed_user',
'unique_bettors_on_your_contract', 'unique_bettors_on_your_contract',
'profit_loss_updates',
// TODO: add these // TODO: add these
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets // biggest winner, here are the rest of your markets
// 'referral_bonuses', // 'referral_bonuses',
@ -153,6 +153,7 @@ export function NotificationSettings(props: {
'trending_markets', 'trending_markets',
'thank_you_for_purchases', 'thank_you_for_purchases',
'onboarding_flow', 'onboarding_flow',
'profit_loss_updates',
], ],
} }

View File

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

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 = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money trading" 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!" 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

@ -105,7 +105,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
sliceTooltip={({ slice }) => { sliceTooltip={({ slice }) => {
handleGraphDisplayChange(slice.points[0].data.yFormatted) handleGraphDisplayChange(slice.points[0].data.yFormatted)
return ( return (
<div className="rounded bg-white px-4 py-2 opacity-80"> <div className="rounded border border-gray-200 bg-white px-4 py-2 opacity-80">
<div <div
key={slice.points[0].id} key={slice.points[0].id}
className="text-xs font-semibold sm:text-sm" className="text-xs font-semibold sm:text-sm"

View File

@ -4,7 +4,6 @@ import { last } from 'lodash'
import { memo, useRef, useState } from 'react' import { memo, useRef, useState } from 'react'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users' import { Period } from 'web/lib/firebase/users'
import { PillButton } from '../buttons/pill-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { PortfolioValueGraph } from './portfolio-value-graph' import { PortfolioValueGraph } from './portfolio-value-graph'
@ -15,7 +14,7 @@ export const PortfolioValueSection = memo(
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value') const [graphMode, setGraphMode] = useState<'profit' | 'value'>('profit')
const [graphDisplayNumber, setGraphDisplayNumber] = useState< const [graphDisplayNumber, setGraphDisplayNumber] = useState<
number | string | null number | string | null
>(null) >(null)
@ -40,24 +39,6 @@ export const PortfolioValueSection = memo(
<> <>
<Row className="mb-2 justify-between"> <Row className="mb-2 justify-between">
<Row className="gap-4 sm:gap-8"> <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 <Col
className={clsx( className={clsx(
'cursor-pointer', 'cursor-pointer',
@ -91,6 +72,25 @@ export const PortfolioValueSection = memo(
: formatMoney(totalProfit)} : formatMoney(totalProfit)}
</div> </div>
</Col> </Col>
<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>
</Row> </Row>
</Row> </Row>
<PortfolioValueGraph <PortfolioValueGraph
@ -146,34 +146,3 @@ export function PortfolioPeriodSelection(props: {
</Row> </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 const { probabilityInt, setProbabilityInt, isSubmitting } = props
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<label className="input-group input-group-lg w-fit text-lg"> <label className="input-group input-group-lg text-lg">
<input <input
type="number" type="number"
value={probabilityInt} value={probabilityInt}
className="input input-bordered input-md text-lg" className="input input-bordered input-md w-28 text-lg"
disabled={isSubmitting} disabled={isSubmitting}
min={1} min={1}
max={99} max={99}
@ -23,14 +23,6 @@ export function ProbabilitySelector(props: {
/> />
<span>%</span> <span>%</span>
</label> </label>
<input
type="range"
className="range range-primary"
min={1}
max={99}
value={probabilityInt}
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
/>
</Row> </Row>
) )
} }

View File

@ -0,0 +1,28 @@
import clsx from 'clsx'
export function ProfitBadge(props: {
profitPercent: number
round?: boolean
className?: string
}) {
const { profitPercent, round, className } = props
if (!profitPercent) return null
const colors =
profitPercent > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
return (
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors,
className
)}
>
{(profitPercent > 0 ? '+' : '') +
profitPercent.toFixed(round ? 0 : 1) +
'%'}
</span>
)
}

View File

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

View File

@ -6,13 +6,15 @@ export const linkClass =
'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2'
export const SiteLink = (props: { export const SiteLink = (props: {
href: string href: string | undefined
children?: ReactNode children?: ReactNode
onClick?: () => void onClick?: () => void
className?: string className?: string
}) => { }) => {
const { href, children, onClick, className } = props const { href, children, onClick, className } = props
if (!href) return <>{children}</>
return ( return (
<MaybeLink href={href}> <MaybeLink href={href}>
<a <a

View File

@ -1,22 +1,17 @@
import { import { useEffect, useRef, useState } from 'react'
ChevronDoubleRightIcon, import toast from 'react-hot-toast'
ChevronLeftIcon, import { debounce, sum } from 'lodash'
ChevronRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { formatMoney } from 'common/util/format'
import { debounce, sum } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { CommentTips } from 'web/hooks/use-tip-txns' import { CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/api' import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { TipButton } from './contract/tip-button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Tooltip } from './tooltip' import { LIKE_TIP_AMOUNT } from 'common/like'
import { formatMoney } from 'common/util/format'
const TIP_SIZE = 10
export function Tipper(prop: { comment: Comment; tips: CommentTips }) { export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop const { comment, tips } = prop
@ -26,6 +21,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const savedTip = tips[myId] ?? 0 const savedTip = tips[myId] ?? 0
const [localTip, setLocalTip] = useState(savedTip) const [localTip, setLocalTip] = useState(savedTip)
// listen for user being set // listen for user being set
const initialized = useRef(false) const initialized = useRef(false)
useEffect(() => { useEffect(() => {
@ -78,71 +74,22 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const addTip = (delta: number) => { const addTip = (delta: number) => {
setLocalTip(localTip + delta) setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta) me && saveTip(me, comment, localTip - savedTip + delta)
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
const canDown = me && localTip > savedTip const canUp =
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5 me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT
return ( return (
<Row className="items-center gap-0.5"> <Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} /> <TipButton
<span className="font-bold">{Math.floor(total)}</span> tipAmount={LIKE_TIP_AMOUNT}
<UpTip totalTipped={total}
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined} onClick={() => addTip(+LIKE_TIP_AMOUNT)}
value={localTip} userTipped={localTip > 0}
disabled={!canUp}
isCompact
/> />
{localTip === 0 ? (
''
) : (
<span
className={clsx(
'ml-1 font-semibold',
localTip > 0 ? 'text-primary' : 'text-red-400'
)}
>
({formatMoney(localTip)} tip)
</span>
)}
</Row> </Row>
) )
} }
function DownTip(props: { onClick?: () => void }) {
const { onClick } = props
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `-${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-red-600 disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
</Tooltip>
)
}
function UpTip(props: { onClick?: () => void; value: number }) {
const { onClick, value } = props
const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-primary disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
</button>
</Tooltip>
)
}

View File

@ -0,0 +1,302 @@
export const DATA = {
AK: {
dimensions:
'M161.1,453.7 l-0.3,85.4 1.6,1 3.1,0.2 1.5,-1.1 h2.6 l0.2,2.9 7,6.8 0.5,2.6 3.4,-1.9 0.6,-0.2 0.3,-3.1 1.5,-1.6 1.1,-0.2 1.9,-1.5 3.1,2.1 0.6,2.9 1.9,1.1 1.1,2.4 3.9,1.8 3.4,6 2.7,3.9 2.3,2.7 1.5,3.7 5,1.8 5.2,2.1 1,4.4 0.5,3.1 -1,3.4 -1.8,2.3 -1.6,-0.8 -1.5,-3.1 -2.7,-1.5 -1.8,-1.1 -0.8,0.8 1.5,2.7 0.2,3.7 -1.1,0.5 -1.9,-1.9 -2.1,-1.3 0.5,1.6 1.3,1.8 -0.8,0.8 c0,0 -0.8,-0.3 -1.3,-1 -0.5,-0.6 -2.1,-3.4 -2.1,-3.4 l-1,-2.3 c0,0 -0.3,1.3 -1,1 -0.6,-0.3 -1.3,-1.5 -1.3,-1.5 l1.8,-1.9 -1.5,-1.5 v-5 h-0.8 l-0.8,3.4 -1.1,0.5 -1,-3.7 -0.6,-3.7 -0.8,-0.5 0.3,5.7 v1.1 l-1.5,-1.3 -3.6,-6 -2.1,-0.5 -0.6,-3.7 -1.6,-2.9 -1.6,-1.1 v-2.3 l2.1,-1.3 -0.5,-0.3 -2.6,0.6 -3.4,-2.4 -2.6,-2.9 -4.8,-2.6 -4,-2.6 1.3,-3.2 v-1.6 l-1.8,1.6 -2.9,1.1 -3.7,-1.1 -5.7,-2.4 h-5.5 l-0.6,0.5 -6.5,-3.9 -2.1,-0.3 -2.7,-5.8 -3.6,0.3 -3.6,1.5 0.5,4.5 1.1,-2.9 1,0.3 -1.5,4.4 3.2,-2.7 0.6,1.6 -3.9,4.4 -1.3,-0.3 -0.5,-1.9 -1.3,-0.8 -1.3,1.1 -2.7,-1.8 -3.1,2.1 -1.8,2.1 -3.4,2.1 -4.7,-0.2 -0.5,-2.1 3.7,-0.6 v-1.3 l-2.3,-0.6 1,-2.4 2.3,-3.9 v-1.8 l0.2,-0.8 4.4,-2.3 1,1.3 h2.7 l-1.3,-2.6 -3.7,-0.3 -5,2.7 -2.4,3.4 -1.8,2.6 -1.1,2.3 -4.2,1.5 -3.1,2.6 -0.3,1.6 2.3,1 0.8,2.1 -2.7,3.2 -6.5,4.2 -7.8,4.2 -2.1,1.1 -5.3,1.1 -5.3,2.3 1.8,1.3 -1.5,1.5 -0.5,1.1 -2.7,-1 -3.2,0.2 -0.8,2.3 h-1 l0.3,-2.4 -3.6,1.3 -2.9,1 -3.4,-1.3 -2.9,1.9 h-3.2 l-2.1,1.3 -1.6,0.8 -2.1,-0.3 -2.6,-1.1 -2.3,0.6 -1,1 -1.6,-1.1 v-1.9 l3.1,-1.3 6.3,0.6 4.4,-1.6 2.1,-2.1 2.9,-0.6 1.8,-0.8 2.7,0.2 1.6,1.3 1,-0.3 2.3,-2.7 3.1,-1 3.4,-0.6 1.3,-0.3 0.6,0.5 h0.8 l1.3,-3.7 4,-1.5 1.9,-3.7 2.3,-4.5 1.6,-1.5 0.3,-2.6 -1.6,1.3 -3.4,0.6 -0.6,-2.4 -1.3,-0.3 -1,1 -0.2,2.9 -1.5,-0.2 -1.5,-5.8 -1.3,1.3 -1.1,-0.5 -0.3,-1.9 -4,0.2 -2.1,1.1 -2.6,-0.3 1.5,-1.5 0.5,-2.6 -0.6,-1.9 1.5,-1 1.3,-0.2 -0.6,-1.8 v-4.4 l-1,-1 -0.8,1.5 h-6.1 l-1.5,-1.3 -0.6,-3.9 -2.1,-3.6 v-1 l2.1,-0.8 0.2,-2.1 1.1,-1.1 -0.8,-0.5 -1.3,0.5 -1.1,-2.7 1,-5 4.5,-3.2 2.6,-1.6 1.9,-3.7 2.7,-1.3 2.6,1.1 0.3,2.4 2.4,-0.3 3.2,-2.4 1.6,0.6 1,0.6 h1.6 l2.3,-1.3 0.8,-4.4 c0,0 0.3,-2.9 1,-3.4 0.6,-0.5 1,-1 1,-1 l-1.1,-1.9 -2.6,0.8 -3.2,0.8 -1.9,-0.5 -3.6,-1.8 -5,-0.2 -3.6,-3.7 0.5,-3.9 0.6,-2.4 -2.1,-1.8 -1.9,-3.7 0.5,-0.8 6.8,-0.5 h2.1 l1,1 h0.6 l-0.2,-1.6 3.9,-0.6 2.6,0.3 1.5,1.1 -1.5,2.1 -0.5,1.5 2.7,1.6 5,1.8 1.8,-1 -2.3,-4.4 -1,-3.2 1,-0.8 -3.4,-1.9 -0.5,-1.1 0.5,-1.6 -0.8,-3.9 -2.9,-4.7 -2.4,-4.2 2.9,-1.9 h3.2 l1.8,0.6 4.2,-0.2 3.7,-3.6 1.1,-3.1 3.7,-2.4 1.6,1 2.7,-0.6 3.7,-2.1 1.1,-0.2 1,0.8 4.5,-0.2 2.7,-3.1 h1.1 l3.6,2.4 1.9,2.1 -0.5,1.1 0.6,1.1 1.6,-1.6 3.9,0.3 0.3,3.7 1.9,1.5 7.1,0.6 6.3,4.2 1.5,-1 5.2,2.6 2.1,-0.6 1.9,-0.8 4.8,1.9z m-115.1,28.9 2.1,5.3 -0.2,1 -2.9,-0.3 -1.8,-4 -1.8,-1.5 h-2.4 l-0.2,-2.6 1.8,-2.4 1.1,2.4 1.5,1.5z m-2.6,33.5 3.7,0.8 3.7,1 0.8,1 -1.6,3.7 -3.1,-0.2 -3.4,-3.6z m-20.7,-14.1 1.1,2.6 1.1,1.6 -1.1,0.8 -2.1,-3.1 v-1.9z m-13.7,73.1 3.4,-2.3 3.4,-1 2.6,0.3 0.5,1.6 1.9,0.5 1.9,-1.9 -0.3,-1.6 2.7,-0.6 2.9,2.6 -1.1,1.8 -4.4,1.1 -2.7,-0.5 -3.7,-1.1 -4.4,1.5 -1.6,0.3z m48.9,-4.5 1.6,1.9 2.1,-1.6 -1.5,-1.3z m2.9,3 1.1,-2.3 2.1,0.3 -0.8,1.9 h-2.4z m23.6,-1.9 1.5,1.8 1,-1.1 -0.8,-1.9z m8.8,-12.5 1.1,5.8 2.9,0.8 5,-2.9 4.4,-2.6 -1.6,-2.4 0.5,-2.4 -2.1,1.3 -2.9,-0.8 1.6,-1.1 1.9,0.8 3.9,-1.8 0.5,-1.5 -2.4,-0.8 0.8,-1.9 -2.7,1.9 -4.7,3.6 -4.8,2.9z m42.3,-19.8 2.4,-1.5 -1,-1.8 -1.8,1z',
abbreviation: 'AK',
name: 'Alaska',
},
HI: {
dimensions:
'M233.1,519.3 l1.9,-3.6 2.3,-0.3 0.3,0.8 -2.1,3.1z m10.2,-3.7 6.1,2.6 2.1,-0.3 1.6,-3.9 -0.6,-3.4 -4.2,-0.5 -4,1.8z m30.7,10 3.7,5.5 2.4,-0.3 1.1,-0.5 1.5,1.3 3.7,-0.2 1,-1.5 -2.9,-1.8 -1.9,-3.7 -2.1,-3.6 -5.8,2.9z m20.2,8.9 1.3,-1.9 4.7,1 0.6,-0.5 6.1,0.6 -0.3,1.3 -2.6,1.5 -4.4,-0.3z m5.3,5.2 1.9,3.9 3.1,-1.1 0.3,-1.6 -1.6,-2.1 -3.7,-0.3z m7,-1.2 2.3,-2.9 4.7,2.4 4.4,1.1 4.4,2.7 v1.9 l-3.6,1.8 -4.8,1 -2.4,-1.5z m16.6,15.6 1.6,-1.3 3.4,1.6 7.6,3.6 3.4,2.1 1.6,2.4 1.9,4.4 4,2.6 -0.3,1.3 -3.9,3.2 -4.2,1.5 -1.5,-0.6 -3.1,1.8 -2.4,3.2 -2.3,2.9 -1.8,-0.2 -3.6,-2.6 -0.3,-4.5 0.6,-2.4 -1.6,-5.7 -2.1,-1.8 -0.2,-2.6 2.3,-1 2.1,-3.1 0.5,-1 -1.6,-1.8z',
abbreviation: 'HI',
name: 'Hawaii',
},
AL: {
dimensions:
'M628.5,466.4 l0.6,0.2 1.3,-2.7 1.5,-4.4 2.3,0.6 3.1,6 v1 l-2.7,1.9 2.7,0.3 5.2,-2.5 -0.3,-7.6 -2.5,-1.8 -2,-2 0.4,-4 10.5,-1.5 25.7,-2.9 6.7,-0.6 5.6,0.1 -0.5,-2.2 -1.5,-0.8 -0.9,-1.1 1,-2.6 -0.4,-5.2 -1.6,-4.5 0.8,-5.1 1.7,-4.8 -0.2,-1.7 -1.8,-0.7 -0.5,-3.6 -2.7,-3.4 -2,-6.5 -1.4,-6.7 -1.8,-5 -3.8,-16 -3.5,-7.9 -0.8,-5.6 0.1,-2.2 -9,0.8 -23.4,2.2 -12.2,0.8 -0.2,6.4 0.2,16.7 -0.7,31 -0.3,14.1 2.8,18.8 1.6,14.7z',
abbreviation: 'AL',
name: 'Alabama',
},
AR: {
dimensions:
'M587.3,346.1 l-6.4,-0.7 0.9,-3.1 3.1,-2.6 0.6,-2.3 -1.8,-2.9 -31.9,1.2 -23.3,0.7 -23.6,0.3 1.5,6.9 0.1,8.5 1.4,10.9 0.3,38.2 2.1,1.6 3,-1.2 2.9,1.2 0.4,10.1 25.2,-0.2 26.8,-0.8 0.9,-1.9 -0.3,-3.8 -1.7,-3.1 1.5,-1.4 -1.4,-2.2 0.7,-2.4 1.1,-5.9 2.7,-2.3 -0.8,-2.2 4,-5.6 2.5,-1.1 -0.1,-1.7 -0.5,-1.7 2.9,-5.8 2.5,-1.1 0.2,-3.3 2.1,-1.4 0.9,-4.1 -1.4,-4 4.2,-2.4 0.3,-2.1 1.2,-4.2 0.9,-3.1z',
abbreviation: 'AR',
name: 'Arkansas',
},
AZ: {
dimensions:
'M135.1,389.7 l-0.3,1.5 0.5,1 18.9,10.7 12.1,7.6 14.7,8.6 16.8,10 12.3,2.4 25.4,2.7 6,-39.6 7,-53.1 4.4,-31 -24.6,-3.6 -60.7,-11 -0.2,1.1 -2.6,16.5 -2.1,3.8 -2.8,-0.2 -1.2,-2.6 -2.6,-0.4 -1.2,-1.1 -1.1,0.1 -2.1,1.7 -0.3,6.8 -0.3,1.5 -0.5,12.5 -1.5,2.4 -0.4,3.3 2.8,5 1.1,5.5 0.7,1.1 1.1,0.9 -0.4,2.4 -1.7,1.2 -3.4,1.6 -1.6,1.8 -1.6,3.6 -0.5,4.9 -3,2.9 -1.9,0.9 -0.1,5.8 -0.6,1.6 0.5,0.8 3.9,0.4 -0.9,3 -1.7,2.4 -3.7,0.4z',
abbreviation: 'AZ',
name: 'Arizona',
},
CA: {
dimensions:
'M122.7,385.9 l-19.7,-2.7 -10,-1.5 -0.5,-1.8 v-9.4 l-0.3,-3.2 -2.6,-4.2 -0.8,-2.3 -3.9,-4.2 -2.9,-4.7 -2.7,-0.2 -3.2,-0.8 -0.3,-1 1.5,-0.6 -0.6,-3.2 -1.5,-2.1 -4.8,-0.8 -3.9,-2.1 -1.1,-2.3 -2.6,-4.8 -2.9,-3.1 h-2.9 l-3.9,-2.1 -4.5,-1.8 -4.2,-0.5 -2.4,-2.7 0.5,-1.9 1.8,-7.1 0.8,-1.9 v-2.4 l-1.6,-1 -0.5,-2.9 -1.5,-2.6 -3.4,-5.8 -1.3,-3.1 -1.5,-4.7 -1.6,-5.3 -3.2,-4.4 -0.5,-2.9 0.8,-3.9 h1.1 l2.1,-1.6 1.1,-3.6 -1,-2.7 -2.7,-0.5 -1.9,-2.6 -2.1,-3.7 -0.2,-8.2 0.6,-1.9 0.6,-2.3 0.5,-2.4 -5.7,-6.3 v-2.1 l0.3,-0.5 0.3,-3.2 -1.3,-4 -2.3,-4.8 -2.7,-4.5 -1.8,-3.9 1,-3.7 0.6,-5.8 1.8,-3.1 0.3,-6.5 -1.1,-3.6 -1.6,-4.2 -2.7,-4.2 0.8,-3.2 1.5,-4.2 1.8,-0.8 0.3,-1.1 3.1,-2.6 5.2,-11.8 0.2,-7.4 1.69,-4.9 38.69,11.8 25.6,6.6 -8,31.3 -8.67,33.1 12.63,19.2 42.16,62.3 17.1,26.1 -0.4,3.1 2.8,5.2 1.1,5.4 1,1.5 0.7,0.6 -0.2,1.4 -1.4,1 -3.4,1.6 -1.9,2.1 -1.7,3.9 -0.5,4.7 -2.6,2.5 -2.3,1.1 -0.1,6.2 -0.6,1.9 1,1.7 3,0.3 -0.4,1.6 -1.4,2 -3.9,0.6z m-73.9,-48.9 1.3,1.5 -0.2,1.3 -3.2,-0.1 -0.6,-1.2 -0.6,-1.5z m1.9,0 1.2,-0.6 3.6,2.1 3.1,1.2 -0.9,0.6 -4.5,-0.2 -1.6,-1.6z m20.7,19.8 1.8,2.3 0.8,1 1.5,0.6 0.6,-1.5 -1,-1.8 -2.7,-2 -1.1,0.2 v1.2z m-1.4,8.7 1.8,3.2 1.2,1.9 -1.5,0.2 -1.3,-1.2 c0,0 -0.7,-1.5 -0.7,-1.9 0,-0.4 0,-2.2 0,-2.2z',
abbreviation: 'CA',
name: 'California',
},
CO: {
dimensions:
'M380.2,235.5 l-36,-3.5 -79.1,-8.6 -2.2,22.1 -7,50.4 -1.9,13.7 34,3.9 37.5,4.4 34.7,3 14.3,0.6z',
abbreviation: 'CO',
name: 'Colorado',
},
CT: {
dimensions:
'M852,190.9 l3.6,-3.2 1.9,-2.1 0.8,0.6 2.7,-1.5 5.2,-1.1 7,-3.5 -0.6,-4.2 -0.8,-4.4 -1.6,-6 -4.3,1.1 -21.8,4.7 0.6,3.1 1.5,7.3 v8.3 l-0.9,2.1 1.7,2.2z',
abbreviation: 'CT',
name: 'Connecticut',
},
DE: {
dimensions:
'M834.4,247.2 l-1,0.5 -3.6,-2.4 -1.8,-4.7 -1.9,-3.6 -2.3,-1 -2.1,-3.6 0.5,-2 0.5,-2.3 0.1,-1.1 -0.6,0.1 -1.7,1 -2,1.7 -0.2,0.3 1.4,4.1 2.3,5.6 3.7,16.1 5,-0.3 6,-1.1z',
abbreviation: 'DE',
name: 'Delaware',
},
FL: {
dimensions:
'M750.2,445.2 l-5.2,-0.7 -0.7,0.8 1.5,4.4 -0.4,5.2 -4.1,-1 -0.2,-2.8 h-4.1 l-5.3,0.7 -32.4,1.9 -8.2,-0.3 -1.7,-1.7 -2.5,-4.2 h-5.9 l-6.6,0.5 -35.4,4.2 -0.3,2.8 1.6,1.6 2.9,2 0.3,8.4 3.3,-0.6 6,-2.1 6,-0.5 4.4,-0.6 7.6,1.8 8.1,3.9 1.6,1.5 2.9,1.1 1.6,1.9 0.3,2.7 3.2,-1.3 h3.9 l3.6,-1.9 3.7,-3.6 3.1,0.2 0.5,-1.1 -0.8,-1 0.2,-1.9 4,-0.8 h2.6 l2.9,1.5 4.2,1.5 2.4,3.7 2.7,1 1.1,3.4 3.4,1.6 1.6,2.6 1.9,0.6 5.2,1.3 1.3,3.1 3,3.7 v9.5 l-1.5,4.7 0.3,2.7 1.3,4.8 1.8,4 0.8,-0.5 1.5,-4.5 -2.6,-1 -0.3,-0.6 1.6,-0.6 4.5,1 0.2,1.6 -3.2,5.5 -2.1,2.4 3.6,3.7 2.6,3.1 2.9,5.3 2.9,3.9 2.1,5 1.8,0.3 1.6,-2.1 1.8,1.1 2.6,4 0.6,3.6 3.1,4.4 0.8,-1.3 3.9,0.3 3.6,2.3 3.4,5.2 0.8,3.4 0.3,2.9 1.1,1 1.3,0.5 2.4,-1 1.5,-1.6 3.9,-0.2 3.1,-1.5 2.7,-3.2 -0.5,-1.9 -0.3,-2.4 0.6,-1.9 -0.3,-1.9 2.4,-1.3 0.3,-3.4 -0.6,-1.8 -0.5,-12 -1.3,-7.6 -4.5,-8.2 -3.6,-5.8 -2.6,-5.3 -2.9,-2.9 -2.9,-7.4 0.7,-1.4 1.1,-1.3 -1.6,-2.9 -4,-3.7 -4.8,-5.5 -3.7,-6.3 -5.3,-9.4 -3.7,-9.7 -2.3,-7.3z m17.7,132.7 2.4,-0.6 1.3,-0.2 1.5,-2.3 2.3,-1.6 1.3,0.5 1.7,0.3 0.4,1.1 -3.5,1.2 -4.2,1.5 -2.3,1.2z m13.5,-5 1.2,1.1 2.7,-2.1 5.3,-4.2 3.7,-3.9 2.5,-6.6 1,-1.7 0.2,-3.4 -0.7,0.5 -1,2.8 -1.5,4.6 -3.2,5.3 -4.4,4.2 -3.4,1.9z',
abbreviation: 'FL',
name: 'Florida',
},
GA: {
dimensions:
'M750.2,444.2 l-5.6,-0.7 -1.4,1.6 1.6,4.7 -0.3,3.9 -2.2,-0.6 -0.2,-3 h-5.2 l-5.3,0.7 -32.3,1.9 -7.7,-0.3 -1.4,-1.2 -2.5,-4.3 -0.8,-3.3 -1.6,-0.9 -0.5,-0.5 0.9,-2.2 -0.4,-5.5 -1.6,-4.5 0.8,-4.9 1.7,-4.8 -0.2,-2.5 -1.9,-0.7 -0.4,-3.2 -2.8,-3.5 -1.9,-6.2 -1.5,-7 -1.7,-4.8 -3.8,-16 -3.5,-8 -0.8,-5.3 0.1,-2.3 3.3,-0.3 13.6,-1.6 18.6,-2 6.3,-1.1 0.5,1.4 -2.2,0.9 -0.9,2.2 0.4,2 1.4,1.6 4.3,2.7 3.2,-0.1 3.2,4.7 0.6,1.6 2.3,2.8 0.5,1.7 4.7,1.8 3,2.2 2.3,3 2.3,1.3 2,1.8 1.4,2.7 2.1,1.9 4.1,1.8 2.7,6 1.7,5.1 2.8,0.7 2.1,1.9 2,5.7 2.9,1.6 1.7,-0.8 0.4,1.2 -3.3,6.2 0.5,2.6 -1.5,4.2 -2.3,10 0.8,6.3z',
abbreviation: 'GA',
name: 'Georgia',
},
IA: {
dimensions:
'M556.8,183.6 l2.1,2.1 0.3,0.7 -2,3 0.3,4 2.6,4.1 3.1,1.6 2.4,0.3 0.9,1.8 0.2,2.4 2.5,1 0.9,1.1 0.5,1.6 3.8,3.3 0.6,1.9 -0.7,3 -1.7,3.7 -0.6,2.4 -2.1,1.6 -1.6,0.5 -5.7,1.5 -1.6,4.8 0.8,1.8 1.7,1.5 -0.2,3.5 -1.9,1.4 -0.7,1.8 v2.4 l-1.4,0.4 -1.7,1.4 -0.5,1.7 0.4,1.7 -1.3,1 -2.3,-2.7 -1.4,-2.8 -8.3,0.8 -10,0.6 -49.2,1.2 -1.6,-4.3 -0.4,-6.7 -1.4,-4.2 -0.7,-5.2 -2.2,-3.7 -1,-4.6 -2.7,-7.8 -1.1,-5.6 -1.4,-1.9 -1.3,-2.9 1.7,-3.8 1.2,-6.1 -2.7,-2.2 -0.3,-2.4 0.7,-2.4 1.8,-0.3 61.1,-0.6 21.2,-0.7z',
abbreviation: 'IA',
name: 'Iowa',
},
ID: {
dimensions:
'M175.3,27.63 l-4.8,17.41 -4.5,20.86 -3.4,16.22 -0.4,9.67 1.2,4.44 3.5,2.66 -0.2,3.91 -3.9,4.4 -4.5,6.6 -0.9,2.9 -1.2,1.1 -1.8,0.8 -4.3,5.3 -0.4,3.1 -0.4,1.1 0.6,1 2.6,-0.1 1.1,2.3 -2.4,5.8 -1.2,4.2 -8.8,35.3 20.7,4.5 39.5,7.9 34.8,6.1 4.9,-29.2 3.8,-24.1 -2.7,-2.4 -0.4,-2.6 -0.8,-1.1 -2.1,1 -0.7,2.6 -3.2,0.5 -3.9,-1.6 -3.8,0.1 -2.5,0.7 -3.4,-1.5 -2.4,0.2 -2.4,2 -2,-1.1 -0.7,-4 0.7,-2.9 -2.5,-2.9 -3.3,-2.6 -2.7,-13.1 -0.1,-4.7 -0.3,-0.1 -0.2,0.4 -5.1,3.5 -1.7,-0.2 -2.9,-3.4 -0.2,-3.1 7,-17.13 -0.4,-1.94 -3.4,-1.15 -0.6,-1.18 -2.6,-3.46 -4.6,-10.23 -3.2,-1.53 -2,-4.95 1.3,-4.63 -3.2,-7.58 4.4,-21.52z',
abbreviation: 'ID',
name: 'Idaho',
},
IL: {
dimensions:
'M618.7,214.3 l-0.8,-2.6 -1.3,-3.7 -1.6,-1.8 -1.5,-2.6 -0.4,-5.5 -15.9,1.8 -17.4,1 h-12.3 l0.2,2.1 2.2,0.9 1.1,1.4 0.4,1.4 3.9,3.4 0.7,2.4 -0.7,3.3 -1.7,3.7 -0.8,2.7 -2.4,1.9 -1.9,0.6 -5.2,1.3 -1.3,4.1 0.6,1.1 1.9,1.8 -0.2,4.3 -2.1,1.6 -0.5,1.3 v2.8 l-1.8,0.6 -1.4,1.2 -0.4,1.2 0.4,2 -1.6,1.3 -0.9,2.8 0.3,3.9 2.3,7 7,7.6 5.7,3.7 v4.4 l0.7,1.2 6.6,0.6 2.7,1.4 -0.7,3.5 -2.2,6.2 -0.8,3 2,3.7 6.4,5.3 4.8,0.8 2.2,5.1 2,3.4 -0.9,2.8 1.5,3.8 1.7,2.1 1.6,-0.3 1,-2.2 2.4,-1.7 2.8,-1 6.1,2.5 0.5,-0.2 v-1.1 l-1.2,-2.7 0.4,-2.8 2.4,-1.6 3.4,-1.2 -0.5,-1.3 -0.8,-2 1.2,-1.3 1,-2.7 v-4 l0.4,-4.9 2.5,-3 1.8,-3.8 2.5,-4 -0.5,-5.3 -1.8,-3.2 -0.3,-3.3 0.8,-5.3 -0.7,-7.2 -1.1,-15.8 -1.4,-15.3 -0.9,-11.7z',
abbreviation: 'IL',
name: 'Illinois',
},
IN: {
dimensions:
'M622.9,216.1 l1.5,1 1.1,-0.3 2.1,-1.9 2.5,-1.8 14.3,-1.1 18.4,-1.8 1.6,15.5 4.9,42.6 -0.6,2.9 1.3,1.6 0.2,1.3 -2.3,1.6 -3.6,1.7 -3.2,0.4 -0.5,4.8 -4.7,3.6 -2.9,4 0.2,2.4 -0.5,1.4 h-3.5 l-1.4,-1.7 -5.2,3 0.2,3.1 -0.9,0.2 -0.5,-0.9 -2.4,-1.7 -3.6,1.5 -1.4,2.9 -1.2,-0.6 -1.6,-1.8 -4.4,0.5 -5.7,1 -2.5,1.3 v-2.6 l0.4,-4.7 2.3,-2.9 1.8,-3.9 2.7,-4.2 -0.5,-5.8 -1.8,-3.1 -0.3,-3.2 0.8,-5.3 -0.7,-7.1 -0.9,-12.6 -2.5,-30.1z',
abbreviation: 'IN',
name: 'Indiana',
},
KS: {
dimensions:
'M485.9,259.5 l-43.8,-0.6 -40.6,-1.2 -21.7,-0.9 -4.3,64.8 24.3,1 44.7,2.1 46.3,0.6 12.6,-0.3 0.7,-35 -1.2,-11.1 -2.5,-2 -2.4,-3 -2.3,-3.6 0.6,-3 1.7,-1.4 v-2.1 l-0.8,-0.7 -2.6,-0.2 -3.5,-3.4z',
abbreviation: 'KS',
name: 'Kansas',
},
KY: {
dimensions:
'M607.2,331.8 l12.6,-0.7 0.1,-4.1 h4.3 l30.4,-3.2 45.1,-4.3 5.6,-3.6 3.9,-2.1 0.1,-1.9 6,-7.8 4.1,-3.6 2.1,-2.4 -3.3,-2 -2.5,-2.7 -3,-3.8 -0.5,-2.2 -2.6,-1.4 -0.9,-1.9 -0.2,-6.1 -2.6,-2 -1.9,-1.1 -0.5,-2.3 -1.3,0.2 -2,1.2 -2.5,2.7 -1.9,-1.7 -2.5,-0.5 -2.4,1.4 h-2.3 l-1.8,-2 -5.6,-0.1 -1.8,-4.5 -2.9,-1.5 -2.1,0.8 -4.2,0.2 -0.5,2.1 1.2,1.5 0.3,2.1 -2.8,2 -3.8,1.8 -2.6,0.4 -0.5,4.5 -4.9,3.6 -2.6,3.7 0.2,2.2 -0.9,2.3 -4.5,-0.1 -1.3,-1.3 -3.9,2.2 0.2,3.3 -2.4,0.6 -0.8,-1.4 -1.7,-1.2 -2.7,1.1 -1.8,3.5 -2.2,-1 -1.4,-1.6 -3.7,0.4 -5.6,1 -2.8,1.3 -1.2,3.4 -1,1 1.5,3.7 -4.2,1.4 -1.9,1.4 -0.4,2.2 1.2,2.4 v2.2 l-1.6,0.4 -6.1,-2.5 -2.3,0.9 -2,1.4 -0.8,1.8 1.7,2.4 -0.9,1.8 -0.1,3.3 -2.4,1.3 -2.1,1.7z',
abbreviation: 'KY',
name: 'Kentucky',
},
LA: {
dimensions:
'M526.9,485.9 l8.1,-0.3 10.3,3.6 6.5,1.1 3.7,-1.5 3.2,1.1 3.2,1 0.8,-2.1 -3.2,-1.1 -2.6,0.5 -2.7,-1.6 0.8,-1.5 3.1,-1 1.8,1.5 1.8,-1 3.2,0.6 1.5,2.4 0.3,2.3 4.5,0.3 1.8,1.8 -0.8,1.6 -1.3,0.8 1.6,1.6 8.4,3.6 3.6,-1.3 1,-2.4 2.6,-0.6 1.8,-1.5 1.3,1 0.8,2.9 -2.3,0.8 0.6,0.6 3.4,-1.3 2.3,-3.4 0.8,-0.5 -2.1,-0.3 0.8,-1.6 -0.2,-1.5 2.1,-0.5 1.1,-1.3 0.6,0.8 0.6,3.1 4.2,0.6 4,1.9 1,1.5 h2.9 l1.1,1 2.3,-3.1 v-1.5 h-1.3 l-3.4,-2.7 -5.8,-0.8 -3.2,-2.3 1.1,-2.7 2.3,0.3 0.2,-0.6 -1.8,-1 v-0.5 h3.2 l1.8,-3.1 -1.3,-1.9 -0.3,-2.7 -1.5,0.2 -1.9,2.1 -0.6,2.6 -3.1,-0.6 -1,-1.8 1.8,-1.9 1.9,-1.7 -2.2,-6.5 -3.4,-3.4 1,-7.3 -0.2,-0.5 -1.3,0.2 -33.1,1.4 -0.8,-2.4 0.8,-8.5 8.6,-14.8 -0.9,-2.6 1.4,-0.4 0.4,-2 -2.2,-2 0.1,-1.9 -2,-4.5 -0.4,-5.1 0.1,-0.7 -26.4,0.8 -25.2,0.1 0.4,9.7 0.7,9.5 0.5,3.7 2.6,4.5 0.9,4.4 4.3,6 0.3,3.1 0.6,0.8 -0.7,8.3 -2.8,4.6 1.2,2.4 -0.5,2.6 -0.8,7.3 -1.3,3 0.2,3.7z',
abbreviation: 'LA',
name: 'Louisiana',
},
MA: {
dimensions:
'M887.5,172.5 l-0.5,-2.3 0.8,-1.5 2.9,-1.5 0.8,3.1 -0.5,1.8 -2.4,1.5 v1 l1.9,-1.5 3.9,-4.5 3.9,-1.9 4.2,-1.5 -0.3,-2.4 -1,-2.9 -1.9,-2.4 -1.8,-0.8 -2.1,0.2 -0.5,0.5 1,1.3 1.5,-0.8 2.1,1.6 0.8,2.7 -1.8,1.8 -2.3,1 -3.6,-0.5 -3.9,-6 -2.3,-2.6 h-1.8 l-1.1,0.8 -1.9,-2.6 0.3,-1.5 2.4,-5.2 -2.9,-4.4 -3.7,1.8 -1.8,2.9 -18.3,4.7 -13.8,2.5 -0.6,10.6 0.7,4.9 22,-4.8 11.2,-2.8 2,1.6 3.4,4.3 2.9,4.7z m12.5,1.4 2.2,-0.7 0.5,-1.7 1,0.1 1,2.3 -1.3,0.5 -3.9,0.1z m-9.4,0.8 2.3,-2.6 h1.6 l1.8,1.5 -2.4,1 -2.2,1z',
abbreviation: 'MA',
name: 'Massachusetts',
},
MD: {
dimensions:
'M834.8,264.1 l1.7,-3.8 0.5,-4.8 -6.3,1.1 -5.8,0.3 -3.8,-16.8 -2.3,-5.5 -1.5,-4.6 -22.2,4.3 -37.6,7.6 2,10.4 4.8,-4.9 2.5,-0.7 1.4,-1.5 1.8,-2.7 1.6,0.7 2.6,-0.2 2.6,-2.1 2,-1.5 2.1,-0.6 1.5,1.1 2.7,1.4 1.9,1.8 1.3,1.4 4.8,1.6 -0.6,2.9 5.8,2.1 2.1,-2.6 3.7,2.5 -2.1,3.3 -0.7,3.3 -1.8,2.6 v2.1 l0.3,0.8 2,1.3 3.4,1.1 4.3,-0.1 3.1,1 2.1,0.3 1,-2.1 -1.5,-2.1 v-1.8 l-2.4,-2.1 -2.1,-5.5 1.3,-5.3 -0.2,-2.1 -1.3,-1.3 c0,0 1.5,-1.6 1.5,-2.3 0,-0.6 0.5,-2.1 0.5,-2.1 l1.9,-1.3 1.9,-1.6 0.5,1 -1.5,1.6 -1.3,3.7 0.3,1.1 1.8,0.3 0.5,5.5 -2.1,1 0.3,3.6 0.5,-0.2 1.1,-1.9 1.6,1.8 -1.6,1.3 -0.3,3.4 2.6,3.4 3.9,0.5 1.6,-0.8 3.2,4.2 1,0.4z m-14.5,0.2 1.1,2.5 0.2,1.8 1.1,1.9 c0,0 0.9,-0.9 0.9,-1.2 0,-0.3 -0.7,-3.1 -0.7,-3.1 l-0.7,-2.3z',
abbreviation: 'MD',
name: 'Maryland',
},
ME: {
dimensions:
'M865.8,91.9 l1.5,0.4 v-2.6 l0.8,-5.5 2.6,-4.7 1.5,-4 -1.9,-2.4 v-6 l0.8,-1 0.8,-2.7 -0.2,-1.5 -0.2,-4.8 1.8,-4.8 2.9,-8.9 2.1,-4.2 h1.3 l1.3,0.2 v1.1 l1.3,2.3 2.7,0.6 0.8,-0.8 v-1 l4,-2.9 1.8,-1.8 1.5,0.2 6,2.4 1.9,1 9.1,29.9 h6 l0.8,1.9 0.2,4.8 2.9,2.3 h0.8 l0.2,-0.5 -0.5,-1.1 2.8,-0.5 1.9,2.1 2.3,3.7 v1.9 l-2.1,4.7 -1.9,0.6 -3.4,3.1 -4.8,5.5 c0,0 -0.6,0 -1.3,0 -0.6,0 -1,-2.1 -1,-2.1 l-1.8,0.2 -1,1.5 -2.4,1.5 -1,1.5 1.6,1.5 -0.5,0.6 -0.5,2.7 -1.9,-0.2 v-1.6 l-0.3,-1.3 -1.5,0.3 -1.8,-3.2 -2.1,1.3 1.3,1.5 0.3,1.1 -0.8,1.3 0.3,3.1 0.2,1.6 -1.6,2.6 -2.9,0.5 -0.3,2.9 -5.3,3.1 -1.3,0.5 -1.6,-1.5 -3.1,3.6 1,3.2 -1.5,1.3 -0.2,4.4 -1.1,6.3 -2.2,-0.9 -0.5,-3.1 -4,-1.1 -0.2,-2.5 -11.7,-37.43z m36.5,15.6 1.5,-1.5 1.4,1.1 0.6,2.4 -1.7,0.9z m6.7,-5.9 1.8,1.9 c0,0 1.3,0.1 1.3,-0.2 0,-0.3 0.2,-2 0.2,-2 l0.9,-0.8 -0.8,-1.8 -2,0.7z',
abbreviation: 'ME',
name: 'Maine',
},
MI: {
dimensions:
'M644.5,211 l19.1,-1.9 0.2,1.1 9.9,-1.5 12,-1.7 0.1,-0.6 0.2,-1.5 2.1,-3.7 2,-1.7 -0.2,-5.1 1.6,-1.6 1.1,-0.3 0.2,-3.6 1.5,-3 1.1,0.6 0.2,0.6 0.8,0.2 1.9,-1 -0.4,-9.1 -3.2,-8.2 -2.3,-9.1 -2.4,-3.2 -2.6,-1.8 -1.6,1.1 -3.9,1.8 -1.9,5 -2.7,3.7 -1.1,0.6 -1.5,-0.6 c0,0 -2.6,-1.5 -2.4,-2.1 0.2,-0.6 0.5,-5 0.5,-5 l3.4,-1.3 0.8,-3.4 0.6,-2.6 2.4,-1.6 -0.3,-10 -1.6,-2.3 -1.3,-0.8 -0.8,-2.1 0.8,-0.8 1.6,0.3 0.2,-1.6 -2.6,-2.2 -1.3,-2.6 h-2.6 l-4.5,-1.5 -5.5,-3.4 h-2.7 l-0.6,0.6 -1,-0.5 -3.1,-2.3 -2.9,1.8 -2.9,2.3 0.3,3.6 1,0.3 2.1,0.5 0.5,0.8 -2.6,0.8 -2.6,0.3 -1.5,1.8 -0.3,2.1 0.3,1.6 0.3,5.5 -3.6,2.1 -0.6,-0.2 v-4.2 l1.3,-2.4 0.6,-2.4 -0.8,-0.8 -1.9,0.8 -1,4.2 -2.7,1.1 -1.8,1.9 -0.2,1 0.6,0.8 -0.6,2.6 -2.3,0.5 v1.1 l0.8,2.4 -1.1,6.1 -1.6,4 0.6,4.7 0.5,1.1 -0.8,2.4 -0.3,0.8 -0.3,2.7 3.6,6 2.9,6.5 1.5,4.8 -0.8,4.7 -1,6 -2.4,5.2 -0.3,2.7 -3.2,3.1z m-33.3,-72.4 -1.3,-1.1 -1.8,-10.4 -3.7,-1.3 -1.7,-2.3 -12.6,-2.8 -2.8,-1.1 -8.1,-2.2 -7.8,-1 -3.9,-5.3 0.7,-0.5 2.7,-0.8 3.6,-2.3 v-1 l0.6,-0.6 6,-1 2.4,-1.9 4.4,-2.1 0.2,-1.3 1.9,-2.9 1.8,-0.8 1.3,-1.8 2.3,-2.3 4.4,-2.4 4.7,-0.5 1.1,1.1 -0.3,1 -3.7,1 -1.5,3.1 -2.3,0.8 -0.5,2.4 -2.4,3.2 -0.3,2.6 0.8,0.5 1,-1.1 3.6,-2.9 1.3,1.3 h2.3 l3.2,1 1.5,1.1 1.5,3.1 2.7,2.7 3.9,-0.2 1.5,-1 1.6,1.3 1.6,0.5 1.3,-0.8 h1.1 l1.6,-1 4,-3.6 3.4,-1.1 6.6,-0.3 4.5,-1.9 2.6,-1.3 1.5,0.2 v5.7 l0.5,0.3 2.9,0.8 1.9,-0.5 6.1,-1.6 1.1,-1.1 1.5,0.5 v7 l3.2,3.1 1.3,0.6 1.3,1 -1.3,0.3 -0.8,-0.3 -3.7,-0.5 -2.1,0.6 -2.3,-0.2 -3.2,1.5 h-1.8 l-5.8,-1.3 -5.2,0.2 -1.9,2.6 -7,0.6 -2.4,0.8 -1.1,3.1 -1.3,1.1 -0.5,-0.2 -1.5,-1.6 -4.5,2.4 h-0.6 l-1.1,-1.6 -0.8,0.2 -1.9,4.4 -1,4 -3.2,6.9z m-29.6,-56.5 1.8,-2.1 2.2,-0.8 5.4,-3.9 2.3,-0.6 0.5,0.5 -5.1,5.1 -3.3,1.9 -2.1,0.9z m86.2,32.1 0.6,2.5 3.2,0.2 1.3,-1.2 c0,0 -0.1,-1.5 -0.4,-1.6 -0.3,-0.2 -1.6,-1.9 -1.6,-1.9 l-2.2,0.2 -1.6,0.2 -0.3,1.1z',
abbreviation: 'MI',
name: 'Michigan',
},
MN: {
dimensions:
'M464.6,66.79 l-0.6,3.91 v10.27 l1.6,5.03 1.9,3.32 0.5,9.93 1.8,13.45 1.8,7.3 0.4,6.4 v5.3 l-1.6,1.8 -1.8,1.3 v1.5 l0.9,1.7 4.1,3.5 0.7,3.2 v35.9 l60.3,-0.6 21.2,-0.7 -0.5,-6 -1.8,-2.1 -7.2,-4.6 -3.6,-5.3 -3.4,-0.9 -2,-2.8 h-3.2 l-3.5,-3.8 -0.5,-7 0.1,-3.9 1.5,-3 -0.7,-2.7 -2.8,-3.1 2.2,-6.1 5.4,-4 1.2,-1.4 -0.2,-8 0.2,-3 2.6,-3 3.8,-2.9 1.3,-0.2 4.5,-5 1.8,-0.8 2.3,-3.9 2.4,-3.6 3.1,-2.6 4.8,-2 9.2,-4.1 3.9,-1.8 0.6,-2.3 -4.4,0.4 -0.7,1.1 h-0.6 l-1.8,-3.1 -8.9,0.3 -1,0.8 h-1 l-0.5,-1.3 -0.8,-1.8 -2.6,0.5 -3.2,3.2 -1.6,0.8 h-3.1 l-2.6,-1 v-2.1 l-1.3,-0.2 -0.5,0.5 -2.6,-1.3 -0.5,-2.9 -1.5,0.5 -0.5,1 -2.4,-0.5 -5.3,-2.4 -3.9,-2.6 h-2.9 l-1.3,-1 -2.3,0.6 -1.1,1.1 -0.3,1.3 h-4.8 v-2.1 l-6.3,-0.3 -0.3,-1.5 h-4.8 l-1.6,-1.6 -1.5,-6.1 -0.8,-5.5 -1.9,-0.8 -2.3,-0.5 -0.6,0.2 -0.3,8.2 -30.1,-0.03z',
abbreviation: 'MN',
name: 'Minnesota',
},
MO: {
dimensions:
'M593.1,338.7 l0.5,-5.9 4.2,-3.4 1.9,-1 v-2.9 l0.7,-1.6 -1.1,-1.6 -2.4,0.3 -2.1,-2.5 -1.7,-4.5 0.9,-2.6 -2,-3.2 -1.8,-4.6 -4.6,-0.7 -6.8,-5.6 -2.2,-4.2 0.8,-3.3 2.2,-6 0.6,-3 -1.9,-1 -6.9,-0.6 -1.1,-1.9 v-4.1 l-5.3,-3.5 -7.2,-7.8 -2.3,-7.3 -0.5,-4.2 0.7,-2.4 -2.6,-3.1 -1.2,-2.4 -7.7,0.8 -10,0.6 -48.8,1.2 1.3,2.6 -0.1,2.2 2.3,3.6 3,3.9 3.1,3 2.6,0.2 1.4,1.1 v2.9 l-1.8,1.6 -0.5,2.3 2.1,3.2 2.4,3 2.6,2.1 1.3,11.6 -0.8,40 0.5,5.7 23.7,-0.2 23.3,-0.7 32.5,-1.3 2.2,3.7 -0.8,3.1 -3.1,2.5 -0.5,1.8 5.2,0.5 4.1,-1.1z',
abbreviation: 'MO',
name: 'Missouri',
},
MS: {
dimensions:
'M604.3,472.5 l2.6,-4.2 1.8,0.8 6.8,-1.9 2.1,0.3 1.5,0.8 h5.2 l0.4,-1.6 -1.7,-14.8 -2.8,-19 1,-45.1 -0.2,-16.7 0.2,-6.3 -4.8,0.3 -19.6,1.6 -13,0.4 -0.2,3.2 -2.8,1.3 -2.6,5.1 0.5,1.6 0.1,2.4 -2.9,1.1 -3.5,5.1 0.8,2.3 -3,2.5 -1,5.7 -0.6,1.9 1.6,2.5 -1.5,1.4 1.5,2.8 0.3,4.2 -1.2,2.5 -0.2,0.9 0.4,5 2,4.5 -0.1,1.7 2.3,2 -0.7,3.1 -0.9,0.3 0.6,1.9 -8.6,15 -0.8,8.2 0.5,1.5 24.2,-0.7 8.2,-0.7 1.9,-0.3 0.6,1.4 -1,7.1 3.3,3.3 2.2,6.4z',
abbreviation: 'MS',
name: 'Mississippi',
},
MT: {
dimensions:
'M361.1,70.77 l-5.3,57.13 -1.3,15.2 -59.1,-6.6 -49,-7.1 -1.4,11.2 -1.9,-1.7 -0.4,-2.5 -1.3,-1.9 -3.3,1.5 -0.7,2.5 -2.3,0.3 -3.8,-1.6 -4.1,0.1 -2.4,0.7 -3.2,-1.5 -3,0.2 -2.1,1.9 -0.9,-0.6 -0.7,-3.4 0.7,-3.2 -2.7,-3.2 -3.3,-2.5 -2.5,-12.6 -0.1,-5.3 -1.6,-0.8 -0.6,1 -4.5,3.2 -1.2,-0.1 -2.3,-2.8 -0.2,-2.8 7,-17.15 -0.6,-2.67 -3.5,-1.12 -0.4,-0.91 -2.7,-3.5 -4.6,-10.41 -3.2,-1.58 -1.8,-4.26 1.3,-4.63 -3.2,-7.57 4.4,-21.29 32.7,6.89 18.4,3.4 32.3,5.3 29.3,4 29.2,3.5 30.8,3.07z',
abbreviation: 'MT',
name: 'Montana',
},
NC: {
dimensions:
'M786.7,357.7 l-12.7,-7.7 -3.1,-0.8 -16.6,2.1 -1.6,-3 -2.8,-2.2 -16.7,0.5 -7.4,0.9 -9.2,4.5 -6.8,2.7 -6.5,1.2 -13.4,1.4 0.1,-4.1 1.7,-1.3 2.7,-0.7 0.7,-3.8 3.9,-2.5 3.9,-1.5 4.5,-3.7 4.4,-2.3 0.7,-3.2 4.1,-3.8 0.7,1 2.5,0.2 2.4,-3.6 1.7,-0.4 2.6,0.3 1.8,-4 2.5,-2.4 0.5,-1.8 0.1,-3.5 4.4,0.1 38.5,-5.6 57.5,-12.3 2,4.8 3.6,6.5 2.4,2.4 0.6,2.3 -2.4,0.2 0.8,0.6 -0.3,4.2 -2.6,1.3 -0.6,2.1 -1.3,2.9 -3.7,1.6 -2.4,-0.3 -1.5,-0.2 -1.6,-1.3 0.3,1.3 v1 h1.9 l0.8,1.3 -1.9,6.3 h4.2 l0.6,1.6 2.3,-2.3 1.3,-0.5 -1.9,3.6 -3.1,4.8 h-1.3 l-1.1,-0.5 -2.7,0.6 -5.2,2.4 -6.5,5.3 -3.4,4.7 -1.9,6.5 -0.5,2.4 -4.7,0.5 -5.1,1.5z m49.3,-26.2 2.6,-2.5 3.2,-2.6 1.5,-0.6 0.2,-2 -0.6,-6.1 -1.5,-2.3 -0.6,-1.9 0.7,-0.2 2.7,5.5 0.4,4.4 -0.2,3.4 -3.4,1.5 -2.8,2.4 -1.1,1.2z',
abbreviation: 'NC',
name: 'North Carolina',
},
ND: {
dimensions:
'M471,126.4 l-0.4,-6.2 -1.8,-7.3 -1.8,-13.61 -0.5,-9.7 -1.9,-3.18 -1.6,-5.32 v-10.41 l0.6,-3.85 -1.8,-5.54 -28.6,-0.59 -18.6,-0.6 -26.5,-1.3 -25.2,-2.16 -0.9,14.42 -4.7,50.94 56.8,3.9 56.9,1.7z',
abbreviation: 'ND',
name: 'North Dakota',
},
NE: {
dimensions:
'M470.3,204.3 l-1,-2.3 -0.5,-1.6 -2.9,-1.6 -4.8,-1.5 -2.2,-1.2 -2.6,0.1 -3.7,0.4 -4.2,1.2 -6,-4.1 -2.2,-2 -10.7,0.6 -41.5,-2.4 -35.6,-2.2 -4.3,43.7 33.1,3.3 -1.4,21.1 21.7,1 40.6,1.2 43.8,0.6 h4.5 l-2.2,-3 -2.6,-3.9 0.1,-2.3 -1.4,-2.7 -1.9,-5.2 -0.4,-6.7 -1.4,-4.1 -0.5,-5 -2.3,-3.7 -1,-4.7 -2.8,-7.9 -1,-5.3z',
abbreviation: 'NE',
name: 'Nebraska',
},
NH: {
dimensions:
'M881.7,141.3 l1.1,-3.2 -2.7,-1.2 -0.5,-3.1 -4.1,-1.1 -0.3,-3 -11.7,-37.48 -0.7,0.08 -0.6,1.6 -0.6,-0.5 -1,-1 -1.5,1.9 -0.2,2.29 0.5,8.41 1.9,2.8 v4.3 l-3.9,4.8 -2.4,0.9 v0.7 l1.1,1.9 v8.6 l-0.8,9.2 -0.2,4.7 1,1.4 -0.2,4.7 -0.5,1.5 1,1.1 5.1,-1.2 13.8,-3.5 1.7,-2.9 4,-1.9z',
abbreviation: 'NH',
name: 'New Hampshire',
},
NJ: {
dimensions:
'M823.7,228.3 l0.1,-1.5 2.7,-1.3 1.7,-2.8 1.7,-2.4 3.3,-3.2 v-1.2 l-6.1,-4.1 -1,-2.7 -2.7,-0.3 -0.1,-0.9 -0.7,-2.2 2.2,-1.1 0.2,-2.9 -1.3,-1.3 0.2,-1.2 1.9,-3.1 v-3.1 l2.5,-3.1 5.6,2.5 6.4,1.9 2.5,1.2 0.1,1.8 -0.5,2.7 0.4,4.5 -2.1,1.9 -1.1,1 0.5,0.5 2.7,-0.3 1.1,-0.8 1.6,3.4 0.2,9.4 0.6,1.1 -1.1,5.5 -3.1,6.5 -2.7,4 -0.8,4.8 -2.1,2.4 h-0.8 l-0.3,-2.7 0.8,-1 -0.2,-1.5 -4,-0.6 -4.8,-2.3 -3.2,-2.9 -1,-2z',
abbreviation: 'NJ',
name: 'New Jersey',
},
NM: {
dimensions:
'M270.2,429.4 l-16.7,-2.6 -1.2,9.6 -15.8,-2 6,-39.7 7,-53.2 4.4,-30.9 34,3.9 37.4,4.4 32,2.8 -0.3,10.8 -1.4,-0.1 -7.4,97.7 -28.4,-1.8 -38.1,-3.7 0.7,6.3z',
abbreviation: 'NM',
name: 'New Mexico',
},
NV: {
dimensions:
'M123.1,173.6 l38.7,8.5 26,5.2 -10.6,53.1 -5.4,29.8 -3.3,15.5 -2.1,11.1 -2.6,16.4 -1.7,3.1 -1.6,-0.1 -1.2,-2.6 -2.8,-0.5 -1.3,-1.1 -1.8,0.1 -0.9,0.8 -1.8,1.3 -0.3,7.3 -0.3,1.5 -0.5,12.4 -1.1,1.8 -16.7,-25.5 -42.1,-62.1 -12.43,-19 8.55,-32.6 8.01,-31.3z',
abbreviation: 'NV',
name: 'Nevada',
},
NY: {
dimensions:
'M843.4,200 l0.5,-2.7 -0.2,-2.4 -3,-1.5 -6.5,-2 -6,-2.6 -0.6,-0.4 -2.7,-0.3 -2,-1.5 -2.1,-5.9 -3.3,-0.5 -2.4,-2.4 -38.4,8.1 -31.6,6 -0.5,-6.5 1.6,-1.2 1.3,-1.1 1,-1.6 1.8,-1.1 1.9,-1.8 0.5,-1.6 2.1,-2.7 1.1,-1 -0.2,-1 -1.3,-3.1 -1.8,-0.2 -1.9,-6.1 2.9,-1.8 4.4,-1.5 4,-1.3 3.2,-0.5 6.3,-0.2 1.9,1.3 1.6,0.2 2.1,-1.3 2.6,-1.1 5.2,-0.5 2.1,-1.8 1.8,-3.2 1.6,-1.9 h2.1 l1.9,-1.1 0.2,-2.3 -1.5,-2.1 -0.3,-1.5 1.1,-2.1 v-1.5 h-1.8 l-1.8,-0.8 -0.8,-1.1 -0.2,-2.6 5.8,-5.5 0.6,-0.8 1.5,-2.9 2.9,-4.5 2.7,-3.7 2.1,-2.4 2.4,-1.8 3.1,-1.2 5.5,-1.3 3.2,0.2 4.5,-1.5 7.4,-2.2 0.7,4.9 2.4,6.5 0.8,5 -1,4.2 2.6,4.5 0.8,2 -0.9,3.2 3.7,1.7 2.7,10.2 v5.8 l-0.6,10.9 0.8,5.4 0.7,3.6 1.5,7.3 v8.1 l-1.1,2.3 2.1,2.7 0.5,0.9 -1.9,1.8 0.3,1.3 1.3,-0.3 1.5,-1.3 2.3,-2.6 1.1,-0.6 1.6,0.6 2.3,0.2 7.9,-3.9 2.9,-2.7 1.3,-1.5 4.2,1.6 -3.4,3.6 -3.9,2.9 -7.1,5.3 -2.6,1 -5.8,1.9 -4,1.1 -1,-0.4z',
abbreviation: 'NY',
name: 'New York',
},
OH: {
dimensions:
'M663.8,211.2 l1.7,15.5 4.8,41.1 3.9,-0.2 2.3,-0.8 3.6,1.8 1.7,4.2 5.4,0.1 1.8,2 h1.7 l2.4,-1.4 3.1,0.5 1.5,1.3 1.8,-2 2.3,-1.4 2.4,-0.4 0.6,2.7 1.6,1 2.6,2 0.8,0.2 2,-0.1 1.2,-0.6 v-2.1 l1.7,-1.5 0.1,-4.8 1.1,-4.2 1.9,-1.3 1,0.7 1,1.1 0.7,0.2 0.4,-0.4 -0.9,-2.7 v-2.2 l1.1,-1.4 2.5,-3.6 1.3,-1.5 2.2,0.5 2.1,-1.5 3,-3.3 2.2,-3.7 0.2,-5.4 0.5,-5 v-4.6 l-1.2,-3.2 1.2,-1.8 1.3,-1.2 -0.6,-2.8 -4.3,-25.6 -6.2,3.7 -3.9,2.3 -3.4,3.7 -4,3.9 -3.2,0.8 -2.9,0.5 -5.5,2.6 -2.1,0.2 -3.4,-3.1 -5.2,0.6 -2.6,-1.5 -2.2,-1.3z',
abbreviation: 'OH',
name: 'Ohio',
},
OK: {
dimensions:
'M411.9,334.9 l-1.8,24.3 -0.9,18 0.2,1.6 4,3.6 1.7,0.9 h0.9 l0.9,-2.1 1.5,1.9 1.6,0.1 0.3,-0.2 0.2,-1.1 2.8,1.4 -0.4,3.5 3.8,0.5 2.5,1 4.2,0.6 2.3,1.6 2.5,-1.7 3.5,0.7 2.2,3.1 1.2,0.1 v2.3 l2.1,0.7 2.5,-2.1 1.8,0.6 2.7,0.1 0.7,2.3 4.4,1.8 1.7,-0.3 1.9,-4.2 h1.3 l1.1,2.1 4.2,0.8 3.4,1.3 3,0.8 1.6,-0.7 0.7,-2.7 h4.5 l1.9,0.9 2.7,-1.9 h1.4 l0.6,1.4 h3.6 l2,-1.8 2.3,0.6 1.7,2.2 3,1.7 3.4,0.9 1.9,1.2 -0.3,-37.6 -1.4,-10.9 -0.1,-8.6 -1.5,-6.6 -0.6,-6.8 0.1,-4.3 -12.6,0.3 -46.3,-0.5 -44.7,-2.1 -41.5,-1.8 -0.4,10.7z',
abbreviation: 'OK',
name: 'Oklahoma',
},
OR: {
dimensions:
'M67.44,158.9 l28.24,7.2 27.52,6.5 17,3.7 8.8,-35.1 1.2,-4.4 2.4,-5.5 -0.7,-1.3 -2.5,0.1 -1.3,-1.8 0.6,-1.5 0.4,-3.3 4.7,-5.7 1.9,-0.9 0.9,-0.8 0.7,-2.7 0.8,-1.1 3.9,-5.7 3.7,-4 0.2,-3.26 -3.4,-2.49 -1.2,-4.55 -13.1,-3.83 -15.3,-3.47 -14.8,0.37 -1.1,-1.31 -5.1,1.84 -4.5,-0.48 -2.4,-1.58 -1.3,0.54 -4.68,-0.29 -1.96,-1.43 -4.84,-1.77 -1.1,-0.07 -4.45,-1.27 -1.76,1.52 -6.26,-0.24 -5.31,-3.85 0.21,-9.28 -2.05,-3.5 -4.1,-0.6 -0.7,-2.5 -2.4,-0.5 -5.8,2.1 -2.3,6.5 -3.2,10 -3.2,6.5 -5,14.1 -6.5,13.6 -8.1,12.6 -1.9,2.9 -0.8,8.6 -1.3,6 2.71,3.5z',
abbreviation: 'OR',
name: 'Oregon',
},
PA: {
dimensions:
'M736.6,192.2 l1.3,-0.5 5.7,-5.5 0.7,6.9 33.5,-6.5 36.9,-7.8 2.3,2.3 3.1,0.4 2,5.6 2.4,1.9 2.8,0.4 0.1,0.1 -2.6,3.2 v3.1 l-1.9,3.1 -0.2,1.9 1.3,1.3 -0.2,1.9 -2.4,1.1 1,3.4 0.2,1.1 2.8,0.3 0.9,2.5 5.9,3.9 v0.4 l-3.1,3 -1.5,2.2 -1.7,2.8 -2.7,1.2 -1.4,0.3 -2.1,1.3 -1.6,1.4 -22.4,4.3 -38.7,7.8 -11.3,1.4 -3.9,0.7 -5.1,-22.4 -4.3,-25.9z',
abbreviation: 'PA',
name: 'Pennsylvania',
},
RI: {
dimensions:
'M873.6,175.7 l-0.8,-4.4 -1.6,-6 5.7,-1.5 1.5,1.3 3.4,4.3 2.8,4.4 -2.8,1.4 -1.3,-0.2 -1.1,1.8 -2.4,1.9 -2.8,1.1z',
abbreviation: 'RI',
name: 'Rhode Island',
},
SC: {
dimensions:
'M759,413.6 l-2.1,-1 -1.9,-5.6 -2.5,-2.3 -2.5,-0.5 -1.5,-4.6 -3,-6.5 -4.2,-1.8 -1.9,-1.8 -1.2,-2.6 -2.4,-2 -2.3,-1.3 -2.2,-2.9 -3.2,-2.4 -4.4,-1.7 -0.4,-1.4 -2.3,-2.8 -0.5,-1.5 -3.8,-5.4 -3.4,0.1 -3.9,-2.5 -1.2,-1.2 -0.2,-1.4 0.6,-1.6 2.7,-1.3 -0.8,-2 6.4,-2.7 9.2,-4.5 7.1,-0.9 16.4,-0.5 2.3,1.9 1.8,3.5 4.6,-0.8 12.6,-1.5 2.7,0.8 12.5,7.4 10.1,8.3 -5.3,5.4 -2.6,6.1 -0.5,6.3 -1.6,0.8 -1.1,2.7 -2.4,0.6 -2.1,3.6 -2.7,2.7 -2.3,3.4 -1.6,0.8 -3.6,3.4 -2.9,0.2 1,3.2 -5,5.3 -2.3,1.6z',
abbreviation: 'SC',
name: 'South Carolina',
},
SD: {
dimensions:
'M471,181.1 l-0.9,3.2 0.4,3 2.6,2 -1.2,5.4 -1.8,4.1 1.5,3.3 0.7,1.1 -1.3,0.1 -0.7,-1.6 -0.6,-2 -3.3,-1.8 -4.8,-1.5 -2.5,-1.3 -2.9,0.1 -3.9,0.4 -3.8,1.2 -5.3,-3.8 -2.7,-2.4 -10.9,0.8 -41.5,-2.4 -35.6,-2.2 1.5,-24.8 2.8,-34 0.4,-5 56.9,3.9 56.9,1.7 v2.7 l-1.3,1.5 -2,1.5 -0.1,2.2 1.1,2.2 4.1,3.4 0.5,2.7 v35.9z',
abbreviation: 'SD',
name: 'South Dakota',
},
TN: {
dimensions:
'M670.8,359.6 l-13.1,1.2 -23.3,2.2 -37.6,2.7 -11.8,0.4 0.9,-0.6 0.9,-4.5 -1.2,-3.6 3.9,-2.3 0.4,-2.5 1.2,-4.3 3,-9.5 0.5,-5.6 0.3,-0.2 12.3,-0.2 13.6,-0.8 0.1,-3.9 3.5,-0.1 30.4,-3.3 54,-5.2 10.3,-1.5 7.6,-0.2 2.4,-1.9 1.3,0.3 -0.1,3.3 -0.4,1.6 -2.4,2.2 -1.6,3.6 -2,-0.4 -2.4,0.9 -2.2,3.3 -1.4,-0.2 -0.8,-1.2 -1.1,0.4 -4.3,4 -0.8,3.1 -4.2,2.2 -4.3,3.6 -3.8,1.5 -4.4,2.8 -0.6,3.6 -2.5,0.5 -2,1.7 -0.2,4.8z',
abbreviation: 'TN',
name: 'Tennessee',
},
TX: {
dimensions:
'M282.8,425.6 l37,3.6 29.3,1.9 7.4,-97.7 54.4,2.4 -1.7,23.3 -1,18 0.2,2 4.4,4.1 2,1.1 h1.8 l0.5,-1.2 0.7,0.9 2.4,0.2 1.1,-0.6 v-0.2 l1,0.5 -0.4,3.7 4.5,0.7 2.4,0.9 4.2,0.7 2.6,1.8 2.8,-1.9 2.7,0.6 2.2,3.1 0.8,0.1 v2.1 l3.3,1.1 2.5,-2.1 1.5,0.5 2.1,0.1 0.6,2.1 5.2,2 2.3,-0.5 1.9,-4 h0.1 l1.1,1.9 4.6,0.9 3.4,1.3 3.2,1 2.4,-1.2 0.7,-2.3 h3.6 l2.1,1 3,-2 h0.4 l0.5,1.4 h4.7 l1.9,-1.8 1.3,0.4 1.7,2.1 3.3,1.9 3.4,1 2.5,1.4 2.7,2 3.1,-1.2 2.1,0.8 0.7,20 0.7,9.5 0.6,4.1 2.6,4.4 0.9,4.5 4.2,5.9 0.3,3.1 0.6,0.8 -0.7,7.7 -2.9,4.8 1.3,2.6 -0.5,2.4 -0.8,7.2 -1.3,3 0.3,4.2 -5.6,1.6 -9.9,4.5 -1,1.9 -2.6,1.9 -2.1,1.5 -1.3,0.8 -5.7,5.3 -2.7,2.1 -5.3,3.2 -5.7,2.4 -6.3,3.4 -1.8,1.5 -5.8,3.6 -3.4,0.6 -3.9,5.5 -4,0.3 -1,1.9 2.3,1.9 -1.5,5.5 -1.3,4.5 -1.1,3.9 -0.8,4.5 0.8,2.4 1.8,7 1,6.1 1.8,2.7 -1,1.5 -3.1,1.9 -5.7,-3.9 -5.5,-1.1 -1.3,0.5 -3.2,-0.6 -4.2,-3.1 -5.2,-1.1 -7.6,-3.4 -2.1,-3.9 -1.3,-6.5 -3.2,-1.9 -0.6,-2.3 0.6,-0.6 0.3,-3.4 -1.3,-0.6 -0.6,-1 1.3,-4.4 -1.6,-2.3 -3.2,-1.3 -3.4,-4.4 -3.6,-6.6 -4.2,-2.6 0.2,-1.9 -5.3,-12.3 -0.8,-4.2 -1.8,-1.9 -0.2,-1.5 -6,-5.3 -2.6,-3.1 v-1.1 l-2.6,-2.1 -6.8,-1.1 -7.4,-0.6 -3.1,-2.3 -4.5,1.8 -3.6,1.5 -2.3,3.2 -1,3.7 -4.4,6.1 -2.4,2.4 -2.6,-1 -1.8,-1.1 -1.9,-0.6 -3.9,-2.3 v-0.6 l-1.8,-1.9 -5.2,-2.1 -7.4,-7.8 -2.3,-4.7 v-8.1 l-3.2,-6.5 -0.5,-2.7 -1.6,-1 -1.1,-2.1 -5,-2.1 -1.3,-1.6 -7.1,-7.9 -1.3,-3.2 -4.7,-2.3 -1.5,-4.4 -2.6,-2.9 -1.7,-0.5z m174.4,141.7 -0.6,-7.1 -2.7,-7.2 -0.6,-7 1.5,-8.2 3.3,-6.9 3.5,-5.4 3.2,-3.6 0.6,0.2 -4.8,6.6 -4.4,6.5 -2,6.6 -0.3,5.2 0.9,6.1 2.6,7.2 0.5,5.2 0.2,1.5z',
abbreviation: 'TX',
name: 'Texas',
},
UT: {
dimensions:
'M228.4,305.9 l24.6,3.6 1.9,-13.7 7,-50.5 2.3,-22 -32.2,-3.5 2.2,-13.1 1.8,-10.6 -34.7,-6.1 -12.5,-2.5 -10.6,52.9 -5.4,30 -3.3,15.4 -1.7,9.2z',
abbreviation: 'UT',
name: 'Utah',
},
VA: {
dimensions:
'M834.7,265.2 l-0.2,2.8 -2.9,3.8 -0.4,4.6 0.5,3.4 -1.8,5 -2.2,1.9 -1.5,-4.6 0.4,-5.4 1.6,-4.2 0.7,-3.3 -0.1,-1.7z m-60.3,44.6 -38.6,5.6 -4.8,-0.1 -2.2,-0.3 -2.5,1.9 -7.3,0.1 -10.3,1.6 -6.7,0.6 4.1,-2.6 4.1,-2.3 v-2.1 l5.7,-7.3 4.1,-3.7 2.2,-2.5 3.6,4.3 3.8,0.9 2.7,-1 2,-1.5 2.4,1.2 4.6,-1.3 1.7,-4.4 2.4,0.7 3.2,-2.3 1.6,0.4 2.8,-3.2 0.2,-2.7 -0.8,-1.2 4.8,-10.5 1.8,-5.2 0.5,-4.7 0.7,-0.2 1.1,1.7 1.5,1.2 3.9,-0.2 1.7,-8.1 3,-0.6 0.8,-2.6 2.8,-2.2 1.1,-2.1 1.8,-4.3 0.1,-4.6 3.6,1.4 6.6,3.1 0.3,-5.2 3.4,1.2 -0.6,2.9 8.6,3.1 1.4,1.8 -0.8,3.3 -1.3,1.3 -0.5,1.7 0.5,2.4 2,1.3 3.9,1.4 2.9,1 4.9,0.9 2.2,2.1 3.2,0.4 0.9,1.2 -0.4,4.7 1.4,1.1 -0.5,1.9 1.2,0.8 -0.2,1.4 -2.7,-0.1 0.1,1.6 2.3,1.5 0.1,1.4 1.8,1.8 0.5,2.5 -2.6,1.4 1.6,1.5 5.8,-1.7 3.7,6.2z',
abbreviation: 'VA',
name: 'Virginia',
},
VT: {
dimensions:
'M832.7,111.3 l2.4,6.5 0.8,5.3 -1,3.9 2.5,4.4 0.9,2.3 -0.7,2.6 3.3,1.5 2.9,10.8 v5.3 l11.5,-2.1 -1,-1.1 0.6,-1.9 0.2,-4.3 -1,-1.4 0.2,-4.7 0.8,-9.3 v-8.5 l-1.1,-1.8 v-1.6 l2.8,-1.1 3.5,-4.4 v-3.6 l-1.9,-2.7 -0.3,-5.79 -26.1,6.79z',
abbreviation: 'VT',
name: 'Vermont',
},
WA: {
dimensions:
'M74.5,67.7 l-2.3,-4.3 -4.1,-0.7 -0.4,-2.4 -2.5,-0.6 -2.9,-0.5 -1.8,1 -2.3,-2.9 0.3,-2.9 2.7,-0.3 1.6,-4 -2.6,-1.1 0.2,-3.7 4.4,-0.6 -2.7,-2.7 -1.5,-7.1 0.6,-2.9 v-7.9 l-1.8,-3.2 2.3,-9.4 2.1,0.5 2.4,2.9 2.7,2.6 3.2,1.9 4.5,2.1 3.1,0.6 2.9,1.5 3.4,1 2.3,-0.2 v-2.4 l1.3,-1.1 2.1,-1.3 0.3,1.1 0.3,1.8 -2.3,0.5 -0.3,2.1 1.8,1.5 1.1,2.4 0.6,1.9 1.5,-0.2 0.2,-1.3 -1,-1.3 -0.5,-3.2 0.8,-1.8 -0.6,-1.5 v-2.6 l1.8,-3.6 -1.1,-2.6 -2.4,-4.8 0.3,-0.8 1.4,-0.8 4.4,1.5 9.7,2.7 8.6,1.9 20,5.7 23,5.7 15,3.49 -4.8,17.56 -4.5,20.83 -3.4,16.25 -0.4,9.18 v0 l-12.9,-3.72 -15.3,-3.47 -14.5,0.32 -1.1,-1.53 -5.7,2.09 -3.9,-0.42 -2.6,-1.79 -1.7,0.65 -4.15,-0.25 -1.72,-1.32 -5.16,-1.82 -1.18,-0.16 -4.8,-1.39 -1.92,1.65 -5.65,-0.25 -4.61,-3.35z m9.6,-55.4 2,-0.2 0.5,1.4 1.5,-1.6 h2.3 l0.8,1.5 -1.5,1.7 0.6,0.8 -0.7,2 -1.4,0.4 c0,0 -0.9,0.1 -0.9,-0.2 0,-0.3 1.5,-2.6 1.5,-2.6 l-1.7,-0.6 -0.3,1.5 -0.7,0.6 -1.5,-2.3z',
abbreviation: 'WA',
name: 'Washington',
},
WI: {
dimensions:
'M541.4,109.9 l2.9,0.5 2.9,-0.6 7.4,-3.2 2.9,-1.9 2.1,-0.8 1.9,1.5 -1.1,1.1 -1.9,3.1 -0.6,1.9 1,0.6 1.8,-1 1.1,-0.2 2.7,0.8 0.6,1.1 1.1,0.2 0.6,-1.1 4,5.3 8.2,1.2 8.2,2.2 2.6,1.1 12.3,2.6 1.6,2.3 3.6,1.2 1.7,10.2 1.6,1.4 1.5,0.9 -1.1,2.3 -1.8,1.6 -2.1,4.7 -1.3,2.4 0.2,1.8 1.5,0.3 1.1,-1.9 1.5,-0.8 0.8,-2.3 1.9,-1.8 2.7,-4 4.2,-6.3 0.8,-0.5 0.3,1 -0.2,2.3 -2.9,6.8 -2.7,5.7 -0.5,3.2 -0.6,2.6 0.8,1.3 -0.2,2.7 -1.9,2.4 -0.5,1.8 0.6,3.6 0.6,3.4 -1.5,2.6 -0.8,2.9 -1,3.1 1.1,2.4 0.6,6.1 1.6,4.5 -0.2,3 -15.9,1.8 -17.5,1 h-12.7 l-0.7,-1.5 -2.9,-0.4 -2.6,-1.3 -2.3,-3.7 -0.3,-3.6 2,-2.9 -0.5,-1.4 -2.1,-2.2 -0.8,-3.3 -0.6,-6.8 -2.1,-2.5 -7,-4.5 -3.8,-5.4 -3.4,-1 -2.2,-2.8 h-3.2 l-2.9,-3.3 -0.5,-6.5 0.1,-3.8 1.5,-3.1 -0.8,-3.2 -2.5,-2.8 1.8,-5.4 5.2,-3.8 1.6,-1.9 -0.2,-8.1 0.2,-2.8 2.4,-2.8z',
abbreviation: 'WI',
name: 'Wisconsin',
},
WV: {
dimensions:
'M758.9,254.3 l5.8,-6 2.6,-0.8 1.6,-1.5 1.5,-2.2 1.1,0.3 3.1,-0.2 4.6,-3.6 1.5,-0.5 1.3,1 2.6,1.2 3,3 -0.4,4.3 -5.4,-2.6 -4.8,-1.8 -0.1,5.9 -2.6,5.7 -2.9,2.4 -0.8,2.3 -3,0.5 -1.7,8.1 -2.8,0.2 -1.1,-1 -1.2,-2 -2.2,0.5 -0.5,5.1 -1.8,5.1 -5,11 0.9,1.4 -0.1,2 -2.2,2.5 -1.6,-0.4 -3.1,2.3 -2.8,-0.8 -1.8,4.9 -3.8,1 -2.5,-1.3 -2.5,1.9 -2.3,0.7 -3.2,-0.8 -3.8,-4.5 -3.5,-2.2 -2.5,-2.5 -2.9,-3.7 -0.5,-2.3 -2.8,-1.7 -0.6,-1.3 -0.2,-5.6 0.3,0.1 2.4,-0.2 1.8,-1 v-2.2 l1.7,-1.5 0.1,-5.2 0.9,-3.6 1.1,-0.7 0.4,0.3 1,1.1 1.7,0.5 1.1,-1.3 -1,-3.1 v-1.6 l3.1,-4.6 1.2,-1.3 2,0.5 2.6,-1.8 3.1,-3.4 2.4,-4.1 0.2,-5.6 0.5,-4.8 v-4.9 l-1.1,-3 0.9,-1.3 0.8,-0.7 4.3,19.3 4.3,-0.8 11.2,-1.3z',
abbreviation: 'WV',
name: 'West Virginia',
},
WY: {
dimensions:
'M353,161.9 l-1.5,25.4 -4.4,44 -2.7,-0.3 -83.3,-9.1 -27.9,-3 2,-12 6.9,-41 3.8,-24.2 1.3,-11.2 48.2,7 59.1,6.5z',
abbreviation: 'WY',
name: 'Wyoming',
},
}

View File

@ -0,0 +1,85 @@
import { zip } from 'lodash'
import Router from 'next/router'
import { useEffect, useState } from 'react'
import { getProbability } from 'common/calculate'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { Customize, USAMap } from './usa-map'
import {
getContractFromSlug,
listenForContract,
} from 'web/lib/firebase/contracts'
export interface StateElectionMarket {
creatorUsername: string
slug: string
isWinRepublican: boolean
state: string
}
export function StateElectionMap(props: { markets: StateElectionMarket[] }) {
const { markets } = props
const contracts = useContracts(markets.map((m) => m.slug))
const probs = contracts.map((c) =>
c ? getProbability(c as CPMMBinaryContract) : 0.5
)
const marketsWithProbs = zip(markets, probs) as [
StateElectionMarket,
number
][]
const stateInfo = marketsWithProbs.map(([market, prob]) => [
market.state,
{
fill: probToColor(prob, market.isWinRepublican),
clickHandler: () =>
Router.push(`/${market.creatorUsername}/${market.slug}`),
},
])
const config = Object.fromEntries(stateInfo) as Customize
return <USAMap customize={config} />
}
const probToColor = (prob: number, isWinRepublican: boolean) => {
const p = isWinRepublican ? prob : 1 - prob
const hue = p > 0.5 ? 350 : 240
const saturation = 100
const lightness = 100 - 50 * Math.abs(p - 0.5)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
}
const useContracts = (slugs: string[]) => {
const [contracts, setContracts] = useState<(Contract | undefined)[]>(
slugs.map(() => undefined)
)
useEffect(() => {
Promise.all(slugs.map((slug) => getContractFromSlug(slug))).then(
(contracts) => setContracts(contracts)
)
}, [slugs])
useEffect(() => {
if (contracts.some((c) => c === undefined)) return
// listen to contract updates
const unsubs = (contracts as Contract[]).map((c, i) =>
listenForContract(
c.id,
(newC) => newC && setContracts(setAt(contracts, i, newC))
)
)
return () => unsubs.forEach((u) => u())
}, [contracts])
return contracts
}
function setAt<T>(arr: T[], i: number, val: T) {
const newArr = [...arr]
newArr[i] = val
return newArr
}

View File

@ -0,0 +1,106 @@
// https://github.com/jb-1980/usa-map-react
// MIT License
import { DATA } from './data'
import { USAState } from './usa-state'
export type ClickHandler<E = SVGPathElement | SVGCircleElement, R = any> = (
e: React.MouseEvent<E, MouseEvent>
) => R
export type GetClickHandler = (stateKey: string) => ClickHandler | undefined
export type CustomizeObj = {
fill?: string
clickHandler?: ClickHandler
}
export interface Customize {
[key: string]: CustomizeObj
}
export type StatesProps = {
hideStateTitle?: boolean
fillStateColor: (stateKey: string) => string
stateClickHandler: GetClickHandler
}
const States = ({
hideStateTitle,
fillStateColor,
stateClickHandler,
}: StatesProps) =>
Object.entries(DATA).map(([stateKey, data]) => (
<USAState
key={stateKey}
hideStateTitle={hideStateTitle}
stateName={data.name}
dimensions={data.dimensions}
state={stateKey}
fill={fillStateColor(stateKey)}
onClickState={stateClickHandler(stateKey)}
/>
))
type USAMapPropTypes = {
onClick?: ClickHandler
width?: number
height?: number
title?: string
defaultFill?: string
customize?: Customize
hideStateTitle?: boolean
className?: string
}
export const USAMap = ({
onClick = (e) => {
console.log(e.currentTarget.dataset.name)
},
width = 959,
height = 593,
title = 'US states map',
defaultFill = '#d3d3d3',
customize,
hideStateTitle,
className,
}: USAMapPropTypes) => {
const fillStateColor = (state: string) =>
customize?.[state]?.fill ? (customize[state].fill as string) : defaultFill
const stateClickHandler = (state: string) => customize?.[state]?.clickHandler
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 959 593"
>
<title>{title}</title>
<g className="outlines">
{States({
hideStateTitle,
fillStateColor,
stateClickHandler,
})}
<g className="DC state">
<path
className="DC1"
fill={fillStateColor('DC1')}
d="M801.8,253.8 l-1.1-1.6 -1-0.8 1.1-1.6 2.2,1.5z"
/>
<circle
className="DC2"
onClick={onClick}
data-name={'DC'}
fill={fillStateColor('DC2')}
stroke="#FFFFFF"
strokeWidth="1.5"
cx="801.3"
cy="251.8"
r="5"
opacity="1"
/>
</g>
</g>
</svg>
)
}

View File

@ -0,0 +1,34 @@
import clsx from 'clsx'
import { ClickHandler } from './usa-map'
type USAStateProps = {
state: string
dimensions: string
fill: string
onClickState?: ClickHandler
stateName: string
hideStateTitle?: boolean
}
export const USAState = ({
state,
dimensions,
fill,
onClickState,
stateName,
hideStateTitle,
}: USAStateProps) => {
return (
<path
d={dimensions}
fill={fill}
data-name={state}
className={clsx(
!!onClickState && 'hover:cursor-pointer hover:contrast-125'
)}
onClick={onClickState}
id={state}
>
{hideStateTitle ? null : <title>{stateName}</title>}
</path>
)
}

View File

@ -8,13 +8,14 @@ import {
PencilIcon, PencilIcon,
ScaleIcon, ScaleIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { CreatorContractsList } from './contract/contracts-grid' import { CreatorContractsList } from './contract/contracts-grid'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
import { SiteLink } from './site-link' import { linkClass, SiteLink } from './site-link'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Linkify } from './linkify' import { Linkify } from './linkify'
@ -35,6 +36,9 @@ import {
hasCompletedStreakToday, hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal' } from 'web/components/profile/betting-streak-modal'
import { LoansModal } from './profile/loans-modal' import { LoansModal } from './profile/loans-modal'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { DOMAIN } from 'common/envs/constants'
export function UserPage(props: { user: User }) { export function UserPage(props: { user: User }) {
const { user } = props const { user } = props
@ -63,6 +67,7 @@ export function UserPage(props: { user: User }) {
}, []) }, [])
const profit = user.profitCached.allTime const profit = user.profitCached.allTime
const referralUrl = `https://${DOMAIN}?referrer=${user?.username}`
return ( return (
<Page key={user.id}> <Page key={user.id}>
@ -83,7 +88,7 @@ export function UserPage(props: { user: User }) {
className="bg-white shadow-sm shadow-indigo-300" className="bg-white shadow-sm shadow-indigo-300"
/> />
{isCurrentUser && ( {isCurrentUser && (
<div className="absolute z-50 ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300"> <div className="absolute ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300">
<SiteLink href="/profile"> <SiteLink href="/profile">
<PencilIcon className="h-5" />{' '} <PencilIcon className="h-5" />{' '}
</SiteLink> </SiteLink>
@ -184,6 +189,28 @@ export function UserPage(props: { user: User }) {
</Row> </Row>
</SiteLink> </SiteLink>
)} )}
{isCurrentUser && (
<div
className={clsx(
linkClass,
'text-greyscale-4 cursor-pointer text-sm'
)}
onClick={(e) => {
e.preventDefault()
copyToClipboard(referralUrl)
toast.success('Referral link copied!', {
icon: <LinkIcon className="h-6 w-6" aria-hidden="true" />,
})
track('copy referral link')
}}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
Earn M$250 per referral
</Row>
</div>
)}
</Row> </Row>
)} )}
<QueryUncontrolledTabs <QueryUncontrolledTabs
@ -192,7 +219,7 @@ export function UserPage(props: { user: User }) {
tabs={[ tabs={[
{ {
title: 'Markets', title: 'Markets',
tabIcon: <ScaleIcon className="h-5" />, stackedTabIcon: <ScaleIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -202,7 +229,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Portfolio', title: 'Portfolio',
tabIcon: <FolderIcon className="h-5" />, stackedTabIcon: <FolderIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -214,7 +241,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Comments', title: 'Comments',
tabIcon: <ChatIcon className="h-5" />, stackedTabIcon: <ChatIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -5,17 +5,18 @@ import { Row } from './layout/row'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { ExclamationIcon } from '@heroicons/react/solid' import { ExclamationIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Button, ColorType, SizeType } from './button'
export function WarningConfirmationButton(props: { export function WarningConfirmationButton(props: {
amount: number | undefined amount: number | undefined
outcome?: 'YES' | 'NO' | undefined
marketType: 'freeResponse' | 'binary' marketType: 'freeResponse' | 'binary'
warning?: string warning?: string
onSubmit: () => void onSubmit: () => void
disabled?: boolean disabled: boolean
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClassName?: string color: ColorType
size: SizeType
}) { }) {
const { const {
amount, amount,
@ -24,53 +25,43 @@ export function WarningConfirmationButton(props: {
disabled, disabled,
isSubmitting, isSubmitting,
openModalButtonClass, openModalButtonClass,
submitButtonClassName, size,
outcome, color,
marketType,
} = props } = props
if (!warning) { if (!warning) {
return ( return (
<button <Button
className={clsx( size={size}
openModalButtonClass, disabled={isSubmitting || disabled}
isSubmitting ? 'loading btn-disabled' : '', className={clsx(openModalButtonClass)}
disabled && 'btn-disabled',
marketType === 'binary'
? !outcome
? 'btn-disabled bg-greyscale-2'
: ''
: ''
)}
onClick={onSubmit} onClick={onSubmit}
color={color}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
: amount : amount
? `Wager ${formatMoney(amount)}` ? `Wager ${formatMoney(amount)}`
: 'Wager'} : 'Wager'}
</button> </Button>
) )
} }
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx(
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: amount ? `Wager ${formatMoney(amount)}` : 'Wager', label: amount ? `Wager ${formatMoney(amount)}` : 'Wager',
size: size,
color: 'yellow',
disabled: isSubmitting,
}} }}
cancelBtn={{ cancelBtn={{
label: 'Cancel', label: 'Cancel',
className: 'btn-warning', className: 'btn btn-warning',
}} }}
submitBtn={{ submitBtn={{
label: 'Submit', label: 'Submit',
className: clsx( className: clsx('btn border-none btn-sm btn-ghost self-center'),
'border-none btn-sm btn-ghost self-center',
submitButtonClassName
),
}} }}
onSubmit={onSubmit} onSubmit={onSubmit}
> >

View File

@ -213,7 +213,7 @@ export function NumberCancelSelector(props: {
return ( return (
<Col className={clsx('gap-2', className)}> <Col className={clsx('gap-2', className)}>
<Button <Button
color={selected === 'NUMBER' ? 'green' : 'gray'} color={selected === 'NUMBER' ? 'indigo' : 'gray'}
onClick={() => onSelect('NUMBER')} onClick={() => onSelect('NUMBER')}
className={clsx('whitespace-nowrap', btnClassName)} className={clsx('whitespace-nowrap', btnClassName)}
> >
@ -244,7 +244,7 @@ function Button(props: {
type="button" type="button"
className={clsx( className={clsx(
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm', 'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
color === 'green' && 'btn-primary text-white', color === 'green' && 'bg-teal-500 bg-teal-600 text-white',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',

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