Merge branch 'main' into answer-probability
This commit is contained in:
commit
50780026b0
|
@ -21,6 +21,25 @@ const computeInvestmentValue = (
|
|||
})
|
||||
}
|
||||
|
||||
export const computeInvestmentValueCustomProb = (
|
||||
bets: Bet[],
|
||||
contract: Contract,
|
||||
p: number
|
||||
) => {
|
||||
return sumBy(bets, (bet) => {
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
const { outcome, shares } = bet
|
||||
|
||||
const betP = outcome === 'YES' ? p : 1 - p
|
||||
|
||||
const payout = betP * shares
|
||||
const value = payout - (bet.loanAmount ?? 0)
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
|
|
|
@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
// this is Phil's deployment
|
||||
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
|
||||
sprigEnvironmentId: 'Tu7kRZPm7daP',
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ export type EnvConfig = {
|
|||
firebaseConfig: FirebaseConfig
|
||||
amplitudeApiKey?: string
|
||||
twitchBotEndpoint?: string
|
||||
sprigEnvironmentId?: string
|
||||
|
||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||
|
@ -56,6 +57,7 @@ type FirebaseConfig = {
|
|||
export const PROD_CONFIG: EnvConfig = {
|
||||
domain: 'manifold.markets',
|
||||
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
||||
sprigEnvironmentId: 'sQcrq9TDqkib',
|
||||
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
||||
|
|
|
@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
|
|||
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
|
||||
const profit = winnings - amount
|
||||
|
||||
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
|
||||
const payout = amount + (1 - DPM_FEES) * profit
|
||||
return { userId, profit, payout }
|
||||
})
|
||||
|
||||
|
|
|
@ -9,4 +9,11 @@ export type Post = {
|
|||
slug: string
|
||||
}
|
||||
|
||||
export type DateDoc = Post & {
|
||||
bounty: number
|
||||
birthday: number
|
||||
type: 'date-doc'
|
||||
contractSlug: string
|
||||
}
|
||||
|
||||
export const MAX_POST_TITLE_LENGTH = 480
|
||||
|
|
|
@ -57,6 +57,7 @@ export type PrivateUser = {
|
|||
|
||||
email?: string
|
||||
weeklyTrendingEmailSent?: boolean
|
||||
weeklyPortfolioUpdateEmailSent?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
|
|
|
@ -55,6 +55,7 @@ Returns the authenticated user.
|
|||
Gets all groups, in no particular order.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `availableToUserId`: Optional. if specified, only groups that the user can
|
||||
join and groups they've already joined will be returned.
|
||||
|
||||
|
@ -64,24 +65,23 @@ Requires no authorization.
|
|||
|
||||
Gets a group by its slug.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]/markets`
|
||||
|
||||
Gets a group's markets by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
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
|
||||
url: string
|
||||
|
||||
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
|
||||
outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC
|
||||
mechanism: string // dpm-2 or cpmm-1
|
||||
|
||||
probability: number
|
||||
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
|
||||
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
|
||||
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
|
||||
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
|
||||
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
|
||||
|
||||
volume: number
|
||||
volume7Days: number
|
||||
|
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
|
|||
|
||||
Parameters:
|
||||
|
||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
|
||||
- `question`: Required. The headline question for the market.
|
||||
- `description`: Required. A long description describing the rules for the market.
|
||||
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||
|
@ -569,6 +572,12 @@ For numeric markets, you must also provide:
|
|||
|
||||
- `min`: The minimum value that the market may resolve to.
|
||||
- `max`: The maximum value that the market may resolve to.
|
||||
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
|
||||
- `initialValue`: An initial value for the market, between min and max, exclusive.
|
||||
|
||||
For multiple choice markets, you must also provide:
|
||||
|
||||
- `answers`: An array of strings, each of which will be a valid answer for the market.
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -582,12 +591,17 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
|||
"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`
|
||||
|
||||
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`
|
||||
|
||||
|
@ -600,15 +614,18 @@ For binary markets:
|
|||
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
||||
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
||||
|
||||
For free response markets:
|
||||
For free response or multiple choice markets:
|
||||
|
||||
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
|
||||
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
|
||||
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100.
|
||||
|
||||
For numeric markets:
|
||||
|
||||
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
||||
- `value`: The value that the market may resolves to.
|
||||
- `probabilityInt`: Required if `value` is present. Should be equal to
|
||||
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
|
||||
- Otherwise: `(value - min) / (max - min)`
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -752,6 +769,7 @@ Requires no authorization.
|
|||
|
||||
## Changelog
|
||||
|
||||
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
|
||||
- 2022-07-15: Add user by username and user by ID APIs
|
||||
- 2022-06-08: Add paging to markets endpoint
|
||||
- 2022-06-05: Add new authorized write endpoints
|
||||
|
|
|
@ -47,7 +47,8 @@
|
|||
"@types/mailgun-js": "0.22.12",
|
||||
"@types/module-alias": "2.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"firebase-functions-test": "0.3.3"
|
||||
"firebase-functions-test": "0.3.3",
|
||||
"puppeteer": "18.0.5"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
export { APIError } from '../../common/api'
|
||||
|
||||
type Output = Record<string, unknown>
|
||||
type AuthedUser = {
|
||||
export type AuthedUser = {
|
||||
uid: string
|
||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
|
|||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||
import { getValues } from './utils'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string().max(MAX_ANSWER_LENGTH),
|
||||
|
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
|||
return answer
|
||||
})
|
||||
|
||||
await addUserToContractFollowers(contractId, auth.uid)
|
||||
|
||||
return answer
|
||||
})
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
|
|||
import { randomString } from '../../common/util/random'
|
||||
|
||||
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 {
|
||||
|
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
|
|||
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 {
|
||||
question,
|
||||
description,
|
||||
|
@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
outcomeType,
|
||||
groupId,
|
||||
visibility = 'public',
|
||||
} = validate(bodySchema, req.body)
|
||||
} = validate(bodySchema, body)
|
||||
|
||||
let min, max, initialProb, isLogScale, answers
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
;({ min, max, initialValue, isLogScale } = validate(
|
||||
numericSchema,
|
||||
req.body
|
||||
))
|
||||
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
|
||||
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
||||
throw new APIError(400, 'Invalid range.')
|
||||
|
||||
|
@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
;({ initialProb } = validate(binarySchema, body))
|
||||
}
|
||||
|
||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
;({ answers } = validate(multipleChoiceSchema, req.body))
|
||||
;({ answers } = validate(multipleChoiceSchema, body))
|
||||
}
|
||||
|
||||
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
|
||||
const newDescription =
|
||||
typeof description === 'string'
|
||||
!description || typeof description === 'string'
|
||||
? {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: description }],
|
||||
content: [{ type: 'text', text: description || ' ' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: description ?? {}
|
||||
: description
|
||||
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
|
@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
return contract
|
||||
})
|
||||
}
|
||||
|
||||
const getSlug = async (question: string) => {
|
||||
const proposedSlug = slugify(question)
|
||||
|
|
|
@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
|
|||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
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(() =>
|
||||
z.intersection(
|
||||
|
@ -35,11 +38,20 @@ const postSchema = z.object({
|
|||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||
content: contentSchema,
|
||||
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) => {
|
||||
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)
|
||||
if (!creator)
|
||||
|
@ -51,14 +63,36 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
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,
|
||||
creatorId: creator.id,
|
||||
slug,
|
||||
title,
|
||||
createdTime: Date.now(),
|
||||
content: content,
|
||||
}
|
||||
contractSlug,
|
||||
})
|
||||
|
||||
await postRef.create(post)
|
||||
if (groupId) {
|
||||
|
|
|
@ -0,0 +1,411 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top; margin-bottom: 30px" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
|
@ -0,0 +1,510 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
And here's some of the biggest changes in your portfolio:
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<tr>
|
||||
<td
|
||||
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
|
||||
<table role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question1Url}}'>
|
||||
{{question1Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question1Prob}}
|
||||
<!-- 9.9%-->
|
||||
<p class='change' style='{{question1ChangeStyle}}'>
|
||||
{{question1Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question2Url}}'>
|
||||
{{question2Title}}
|
||||
<!-- Will the US economy recover from the pandemic? blah blah blah-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question2Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question2ChangeStyle}}'>
|
||||
{{question2Change}}
|
||||
<!-- +7%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question3Url}}'>
|
||||
{{question3Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question3Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question3ChangeStyle}}'>
|
||||
{{question3Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question4Url}}'>
|
||||
{{question4Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question4Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question4ChangeStyle}}'>
|
||||
{{question4Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
|
@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
|||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getUser } from './utils'
|
||||
import { contractUrl, getUser } from './utils'
|
||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||
import { notification_reason_types } from '../../common/notification'
|
||||
import { Dictionary } from 'lodash'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import {
|
||||
getNotificationDestinationsForUser,
|
||||
notification_preference,
|
||||
} from '../../common/user-notification-preferences'
|
||||
PerContractInvestmentsData,
|
||||
OverallPerformanceData,
|
||||
} from './weekly-portfolio-emails'
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
reason: notification_reason_types,
|
||||
|
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
|
|||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
|
|||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold Markets one week anniversary gift',
|
||||
|
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
|
|||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Create your own prediction market',
|
||||
|
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
|
|||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'thank_you_for_purchases' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'thank_you_for_purchases'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
|
|||
)
|
||||
return
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'trending_markets' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'trending_markets'
|
||||
)
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
function imageSourceUrl(contract: Contract) {
|
||||
return buildCardUrl(getOpenGraphProps(contract))
|
||||
}
|
||||
|
@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async (
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendWeeklyPortfolioUpdateEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser,
|
||||
investments: PerContractInvestmentsData[],
|
||||
overallPerformance: OverallPerformanceData
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
||||
)
|
||||
return
|
||||
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'profit_loss_updates'
|
||||
)
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const templateData: Record<string, string> = {
|
||||
name: firstName,
|
||||
unsubscribeUrl,
|
||||
...overallPerformance,
|
||||
}
|
||||
investments.forEach((investment, i) => {
|
||||
templateData[`question${i + 1}Title`] = investment.questionTitle
|
||||
templateData[`question${i + 1}Url`] = investment.questionUrl
|
||||
templateData[`question${i + 1}Prob`] = investment.questionProb
|
||||
templateData[`question${i + 1}Change`] = formatMoney(investment.difference)
|
||||
templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle
|
||||
})
|
||||
|
||||
await sendTemplateEmail(
|
||||
// privateUser.email,
|
||||
'iansphilips@gmail.com',
|
||||
`Here's your weekly portfolio update!`,
|
||||
investments.length === 0
|
||||
? 'portfolio-update-no-movers'
|
||||
: 'portfolio-update',
|
||||
templateData
|
||||
)
|
||||
}
|
||||
|
|
|
@ -27,9 +27,10 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './reset-weekly-emails-flags'
|
||||
export * from './on-update-contract-follow'
|
||||
export * from './on-update-like'
|
||||
export * from './weekly-portfolio-emails'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
|
|
@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
|
|||
'contract',
|
||||
'closed',
|
||||
user,
|
||||
'closed' + contract.id.slice(6, contract.id.length),
|
||||
contract.id + '-closed-at-' + contract.closeTime,
|
||||
contract.closeTime?.toString() ?? new Date().toString(),
|
||||
{ contract }
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { getAllPrivateUsers } from './utils'
|
||||
|
||||
export const resetWeeklyEmailsFlag = functions
|
||||
export const resetWeeklyEmailsFlags = functions
|
||||
.runWith({
|
||||
timeoutSeconds: 300,
|
||||
memory: '4GB',
|
||||
|
@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
|
|||
privateUsers.map(async (user) => {
|
||||
return firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: false,
|
||||
weeklyPortfolioUpdateEmailSent: false,
|
||||
})
|
||||
})
|
||||
)
|
|
@ -5,6 +5,7 @@ import { Bet } from '../../common/bet'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { log } from './utils'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||
|
||||
export const scoreContracts = functions
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
|
@ -16,11 +17,12 @@ const firestore = admin.firestore()
|
|||
|
||||
async function scoreContractsInternal() {
|
||||
const now = Date.now()
|
||||
const lastHour = now - 60 * 60 * 1000
|
||||
const last3Days = now - 1000 * 60 * 60 * 24 * 3
|
||||
const hourAgo = now - HOUR_MS
|
||||
const dayAgo = now - DAY_MS
|
||||
const threeDaysAgo = now - DAY_MS * 3
|
||||
const activeContractsSnap = await firestore
|
||||
.collection('contracts')
|
||||
.where('lastUpdatedTime', '>', lastHour)
|
||||
.where('lastUpdatedTime', '>', hourAgo)
|
||||
.get()
|
||||
const activeContracts = activeContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
|
@ -41,15 +43,21 @@ async function scoreContractsInternal() {
|
|||
for (const contract of contracts) {
|
||||
const bets = await firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('createdTime', '>', last3Days)
|
||||
.where('createdTime', '>', threeDaysAgo)
|
||||
.get()
|
||||
const bettors = bets.docs
|
||||
.map((doc) => doc.data() as Bet)
|
||||
.map((bet) => bet.userId)
|
||||
const popularityScore = uniq(bettors).length
|
||||
|
||||
const wasCreatedToday = contract.createdTime > dayAgo
|
||||
|
||||
let dailyScore: number | undefined
|
||||
if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') {
|
||||
if (
|
||||
contract.outcomeType === 'BINARY' &&
|
||||
contract.mechanism === 'cpmm-1' &&
|
||||
!wasCreatedToday
|
||||
) {
|
||||
const percentChange = Math.abs(contract.probChanges.day)
|
||||
dailyScore = popularityScore * percentChange
|
||||
}
|
||||
|
|
52
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal file
52
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal 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()
|
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
|
||||
|
||||
import { data } from './criticism-and-red-teaming'
|
||||
|
||||
// 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'
|
||||
|
||||
type CEPSubmission = {
|
||||
title: string
|
||||
author?: string
|
||||
link: string
|
||||
}
|
||||
|
||||
// Use the API to create a new market for this Cause Exploration Prize submission
|
||||
async function postMarket(submission: CEPSubmission) {
|
||||
const { title, author } = submission
|
||||
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Key ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcomeType: 'BINARY',
|
||||
question: `"${title}" by ${author ?? 'anonymous'}`,
|
||||
description: makeDescription(submission),
|
||||
closeTime: Date.parse('2022-09-30').valueOf(),
|
||||
initialProb: 10,
|
||||
// Super secret options:
|
||||
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
|
||||
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
|
||||
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
|
||||
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
|
||||
visibility: 'unlisted',
|
||||
// TODO: Increase liquidity?
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
console.log('Created market:', data.slug)
|
||||
}
|
||||
|
||||
async function postAll() {
|
||||
for (const submission of data.slice(0, 3)) {
|
||||
await postMarket(submission)
|
||||
}
|
||||
}
|
||||
postAll()
|
||||
|
||||
/* Example curl request:
|
||||
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}'
|
||||
--data-raw '{"outcomeType":"BINARY", \
|
||||
"question":"Is there life on Mars?", \
|
||||
"description":"I'm not going to type some long ass example description.", \
|
||||
"closeTime":1700000000000, \
|
||||
"initialProb":25}'
|
||||
*/
|
||||
|
||||
function makeDescription(submission: CEPSubmission) {
|
||||
const { title, author, link } = submission
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
|
||||
{
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
href: link,
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
type: 'text',
|
||||
text: title,
|
||||
},
|
||||
{ text: '" win any prize in the ', type: 'text' },
|
||||
{
|
||||
text: 'EA Criticism and Red Teaming Contest',
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: '?', type: 'text' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
{ type: 'paragraph' },
|
||||
{
|
||||
type: 'iframe',
|
||||
attrs: {
|
||||
allowfullscreen: true,
|
||||
src: link,
|
||||
frameborder: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'doc',
|
||||
}
|
||||
}
|
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
File diff suppressed because it is too large
Load Diff
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
|
||||
import * as fs from 'fs'
|
||||
import * as puppeteer from 'puppeteer'
|
||||
|
||||
export function scrapeEA(contestLink: string, fileName: string) {
|
||||
;(async () => {
|
||||
const browser = await puppeteer.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
await page.goto(contestLink)
|
||||
|
||||
let loadMoreButton = await page.$('.LoadMore-root')
|
||||
|
||||
while (loadMoreButton) {
|
||||
await loadMoreButton.click()
|
||||
await page.waitForNetworkIdle()
|
||||
loadMoreButton = await page.$('.LoadMore-root')
|
||||
}
|
||||
|
||||
/* Run javascript inside the page */
|
||||
const data = await page.evaluate(() => {
|
||||
const list = []
|
||||
const items = document.querySelectorAll('.PostsItem2-root')
|
||||
|
||||
for (const item of items) {
|
||||
const link =
|
||||
'https://forum.effectivealtruism.org' +
|
||||
item?.querySelector('a')?.getAttribute('href')
|
||||
|
||||
// Replace '&' with '&'
|
||||
const clean = (str: string | undefined) => str?.replace(/&/g, '&')
|
||||
|
||||
list.push({
|
||||
title: clean(item?.querySelector('a>span>span')?.innerHTML),
|
||||
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
|
||||
link: link,
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
`./src/scripts/contest/${fileName}.ts`,
|
||||
`export const data = ${JSON.stringify(data, null, 2)}`
|
||||
)
|
||||
|
||||
console.log(data)
|
||||
await browser.close()
|
||||
})()
|
||||
}
|
||||
|
||||
scrapeEA(
|
||||
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
|
||||
'criticism-and-red-teaming'
|
||||
)
|
|
@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { testscheduledfunction } from './test-scheduled-function'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
|||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
||||
|
|
17
functions/src/test-scheduled-function.ts
Normal file
17
functions/src/test-scheduled-function.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
|
||||
import { isProd } from './utils'
|
||||
|
||||
// Function for testing scheduled functions locally
|
||||
export const testscheduledfunction = newEndpoint(
|
||||
{ method: 'GET', memory: '4GiB' },
|
||||
async (_req) => {
|
||||
if (isProd())
|
||||
throw new APIError(400, 'This function is only available in dev mode')
|
||||
|
||||
// Replace your function here
|
||||
await sendPortfolioUpdateEmailsToAllUsers()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
)
|
|
@ -17,7 +17,8 @@ import {
|
|||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { Group } from 'common/group'
|
||||
import { Group } from '../../common/group'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -27,28 +28,46 @@ export const updateMetrics = functions
|
|||
.onRun(updateMetricsCore)
|
||||
|
||||
export async function updateMetricsCore() {
|
||||
const [users, contracts, bets, allPortfolioHistories, groups] =
|
||||
await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||
getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collectionGroup('portfolioHistory')
|
||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||
),
|
||||
getValues<Group>(firestore.collection('groups')),
|
||||
])
|
||||
console.log('Loading users')
|
||||
const users = await getValues<User>(firestore.collection('users'))
|
||||
|
||||
console.log('Loading contracts')
|
||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||
|
||||
console.log('Loading portfolio history')
|
||||
const allPortfolioHistories = await getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collectionGroup('portfolioHistory')
|
||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||
)
|
||||
|
||||
console.log('Loading groups')
|
||||
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||
|
||||
console.log('Loading bets')
|
||||
const contractBets = await batchedWaitAll(
|
||||
contracts
|
||||
.filter((c) => c.id)
|
||||
.map(
|
||||
(c) => () =>
|
||||
getValues<Bet>(
|
||||
firestore.collection('contracts').doc(c.id).collection('bets')
|
||||
)
|
||||
),
|
||||
100
|
||||
)
|
||||
const bets = contractBets.flat()
|
||||
|
||||
console.log('Loading group contracts')
|
||||
const contractsByGroup = await Promise.all(
|
||||
groups.map((group) => {
|
||||
return getValues(
|
||||
groups.map((group) =>
|
||||
getValues(
|
||||
firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.collection('groupContracts')
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
log(
|
||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||
|
|
|
@ -170,3 +170,7 @@ export const chargeUser = (
|
|||
export const getContractPath = (contract: Contract) => {
|
||||
return `/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
export function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
|
|
@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
? await getAllPrivateUsers()
|
||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return (
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
const privateUsersToSendEmailsTo = privateUsers
|
||||
.filter((user) => {
|
||||
return (
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
.slice(150) // Send the emails out in batches
|
||||
log(
|
||||
'Sending weekly trending emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
|
@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
trendingContracts.map((c) => c.question).join('\n ')
|
||||
)
|
||||
|
||||
// TODO: convert to Promise.all
|
||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
|
@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
})
|
||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
||||
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// choose random subset of contracts to send to user
|
||||
|
|
295
functions/src/weekly-portfolio-emails.ts
Normal file
295
functions/src/weekly-portfolio-emails.ts
Normal file
|
@ -0,0 +1,295 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract, CPMMContract } from '../../common/contract'
|
||||
import {
|
||||
getAllPrivateUsers,
|
||||
getPrivateUser,
|
||||
getUser,
|
||||
getValue,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
} from './utils'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { partition, sortBy, sum, uniq } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
|
||||
import { sendWeeklyPortfolioUpdateEmail } from './emails'
|
||||
import { contractUrl } from './utils'
|
||||
import { Txn } from '../../common/txn'
|
||||
import { formatMoney } from '../../common/util/format'
|
||||
import { getContractBetMetrics } from '../../common/calculate'
|
||||
|
||||
export const weeklyPortfolioUpdateEmails = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('* 19 * * 5')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
await sendPortfolioUpdateEmailsToAllUsers()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||
const privateUsers = isProd()
|
||||
? // ian & stephen's ids
|
||||
// filterDefined([
|
||||
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
|
||||
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
|
||||
// ])
|
||||
await getAllPrivateUsers()
|
||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers
|
||||
.filter((user) => {
|
||||
return isProd()
|
||||
? user.notificationPreferences.profit_loss_updates.includes('email') &&
|
||||
!user.weeklyPortfolioUpdateEmailSent
|
||||
: true
|
||||
})
|
||||
// Send emails in batches
|
||||
.slice(0, 200)
|
||||
log(
|
||||
'Sending weekly portfolio emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
'users'
|
||||
)
|
||||
|
||||
const usersBets: { [userId: string]: Bet[] } = {}
|
||||
// get all bets made by each user
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Bet>(
|
||||
firestore.collectionGroup('bets').where('userId', '==', user.id)
|
||||
).then((bets) => {
|
||||
usersBets[user.id] = bets
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const usersToContractsCreated: { [userId: string]: Contract[] } = {}
|
||||
// Get all contracts created by each user
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||
).then((contracts) => {
|
||||
usersToContractsCreated[user.id] = contracts
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Get all txns the users received over the past week
|
||||
const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Txn>(
|
||||
firestore
|
||||
.collection(`txns`)
|
||||
.where('toId', '==', user.id)
|
||||
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||
).then((txn) => {
|
||||
usersToTxnsReceived[user.id] = txn
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Get a flat map of all the bets that users made to get the contracts they bet on
|
||||
const contractsUsersBetOn = filterDefined(
|
||||
await Promise.all(
|
||||
uniq(
|
||||
Object.values(usersBets).flatMap((bets) =>
|
||||
bets.map((bet) => bet.contractId)
|
||||
)
|
||||
).map((contractId) =>
|
||||
getValue<Contract>(firestore.collection('contracts').doc(contractId))
|
||||
)
|
||||
)
|
||||
)
|
||||
log('Found', contractsUsersBetOn.length, 'contracts')
|
||||
let count = 0
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||
const user = await getUser(privateUser.id)
|
||||
if (!user) return
|
||||
const userBets = usersBets[privateUser.id] as Bet[]
|
||||
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||
userBets.some((bet) => bet.contractId === contract.id)
|
||||
)
|
||||
const contractsBetOnInLastWeek = uniq(
|
||||
userBets
|
||||
.filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
|
||||
.map((bet) => bet.contractId)
|
||||
)
|
||||
const totalTips = sum(
|
||||
usersToTxnsReceived[privateUser.id]
|
||||
.filter((txn) => txn.category === 'TIP')
|
||||
.map((txn) => txn.amount)
|
||||
)
|
||||
const greenBg = 'rgba(0,160,0,0.2)'
|
||||
const redBg = 'rgba(160,0,0,0.2)'
|
||||
const clearBg = 'rgba(255,255,255,0)'
|
||||
const roundedProfit =
|
||||
Math.round(user.profitCached.weekly) === 0
|
||||
? 0
|
||||
: Math.floor(user.profitCached.weekly)
|
||||
const performanceData = {
|
||||
profit: formatMoney(user.profitCached.weekly),
|
||||
profit_style: `background-color: ${
|
||||
roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
|
||||
}`,
|
||||
markets_created:
|
||||
usersToContractsCreated[privateUser.id].length.toString(),
|
||||
tips_received: formatMoney(totalTips),
|
||||
unique_bettors: usersToTxnsReceived[privateUser.id]
|
||||
.filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
|
||||
.length.toString(),
|
||||
markets_traded: contractsBetOnInLastWeek.length.toString(),
|
||||
prediction_streak:
|
||||
(user.currentBettingStreak?.toString() ?? '0') + ' days',
|
||||
// More options: bonuses, tips given,
|
||||
} as OverallPerformanceData
|
||||
|
||||
const investmentValueDifferences = sortBy(
|
||||
filterDefined(
|
||||
contractsUserBetOn.map((contract) => {
|
||||
const cpmmContract = contract as CPMMContract
|
||||
if (cpmmContract === undefined || cpmmContract.prob === undefined)
|
||||
return
|
||||
const bets = userBets.filter(
|
||||
(bet) => bet.contractId === contract.id
|
||||
)
|
||||
const previousBets = bets.filter(
|
||||
(b) => b.createdTime < Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const betsInLastWeek = bets.filter(
|
||||
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const marketProbabilityAWeekAgo =
|
||||
cpmmContract.prob - cpmmContract.probChanges.week
|
||||
const currentMarketProbability = cpmmContract.resolutionProbability
|
||||
? cpmmContract.resolutionProbability
|
||||
: cpmmContract.prob
|
||||
|
||||
// TODO: returns 0 for resolved markets - doesn't include them
|
||||
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
marketProbabilityAWeekAgo
|
||||
)
|
||||
const currentBetsMadeAWeekAgoValue =
|
||||
computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
currentMarketProbability
|
||||
)
|
||||
const betsMadeInLastWeekProfit = getContractBetMetrics(
|
||||
contract,
|
||||
betsInLastWeek
|
||||
).profit
|
||||
const marketChange =
|
||||
currentMarketProbability - marketProbabilityAWeekAgo
|
||||
const profit =
|
||||
betsMadeInLastWeekProfit +
|
||||
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
|
||||
return {
|
||||
currentValue: currentBetsMadeAWeekAgoValue,
|
||||
pastValue: betsMadeAWeekAgoValue,
|
||||
difference: profit,
|
||||
contractSlug: contract.slug,
|
||||
marketProbAWeekAgo: marketProbabilityAWeekAgo,
|
||||
questionTitle: contract.question,
|
||||
questionUrl: contractUrl(contract),
|
||||
questionProb: cpmmContract.resolution
|
||||
? cpmmContract.resolution
|
||||
: Math.round(cpmmContract.prob * 100) + '%',
|
||||
questionChange:
|
||||
(marketChange > 0 ? '+' : '') +
|
||||
Math.round(marketChange * 100) +
|
||||
'%',
|
||||
questionChangeStyle: `color: ${
|
||||
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
|
||||
};`,
|
||||
} as PerContractInvestmentsData
|
||||
})
|
||||
),
|
||||
(differences) => Math.abs(differences.difference)
|
||||
).reverse()
|
||||
|
||||
log(
|
||||
'Found',
|
||||
investmentValueDifferences.length,
|
||||
'investment differences for user',
|
||||
privateUser.id
|
||||
)
|
||||
|
||||
const [winningInvestments, losingInvestments] = partition(
|
||||
investmentValueDifferences.filter(
|
||||
(diff) =>
|
||||
diff.pastValue > 0.01 &&
|
||||
Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1%
|
||||
),
|
||||
(investmentsData: PerContractInvestmentsData) => {
|
||||
return investmentsData.difference > 0
|
||||
}
|
||||
)
|
||||
// pick 3 winning investments and 3 losing investments
|
||||
const topInvestments = winningInvestments.slice(0, 2)
|
||||
const worstInvestments = losingInvestments.slice(0, 2)
|
||||
// if no bets in the last week ANd no market movers AND no markets created, don't send email
|
||||
if (
|
||||
contractsBetOnInLastWeek.length === 0 &&
|
||||
topInvestments.length === 0 &&
|
||||
worstInvestments.length === 0 &&
|
||||
usersToContractsCreated[privateUser.id].length === 0
|
||||
) {
|
||||
log('No bets in last week, no market movers, no markets created')
|
||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||
weeklyPortfolioUpdateEmailSent: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
await sendWeeklyPortfolioUpdateEmail(
|
||||
user,
|
||||
privateUser,
|
||||
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||
performanceData
|
||||
)
|
||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||
weeklyPortfolioUpdateEmailSent: true,
|
||||
})
|
||||
log('Sent weekly portfolio update email to', privateUser.email)
|
||||
count++
|
||||
log('sent out emails to user count:', count)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export type PerContractInvestmentsData = {
|
||||
questionTitle: string
|
||||
questionUrl: string
|
||||
questionProb: string
|
||||
questionChange: string
|
||||
questionChangeStyle: string
|
||||
currentValue: number
|
||||
pastValue: number
|
||||
difference: number
|
||||
}
|
||||
|
||||
export type OverallPerformanceData = {
|
||||
profit: string
|
||||
prediction_streak: string
|
||||
markets_traded: string
|
||||
profit_style: string
|
||||
tips_received: string
|
||||
markets_created: string
|
||||
unique_bettors: string
|
||||
}
|
|
@ -6,6 +6,7 @@ import { Col } from './layout/col'
|
|||
import { SiteLink } from './site-link'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function AmountInput(props: {
|
||||
amount: number | undefined
|
||||
|
@ -34,46 +35,53 @@ export function AmountInput(props: {
|
|||
const isInvalid = !str || isNaN(amount)
|
||||
onChange(isInvalid ? undefined : amount)
|
||||
}
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group mb-4">
|
||||
<span className="bg-gray-200 text-sm">{label}</span>
|
||||
<input
|
||||
className={clsx(
|
||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||
error && 'input-error',
|
||||
inputClassName
|
||||
)}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
maxLength={6}
|
||||
autoFocus={!isMobile}
|
||||
value={amount ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAmountChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||
{error === 'Insufficient balance' ? (
|
||||
<>
|
||||
Not enough funds.
|
||||
<span className="ml-1 text-indigo-500">
|
||||
<SiteLink href="/add-funds">Buy more?</SiteLink>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
return (
|
||||
<>
|
||||
<Col className={className}>
|
||||
<label className="font-sm md:font-lg">
|
||||
<span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
className={clsx(
|
||||
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
|
||||
error && 'input-error',
|
||||
isMobile ? 'w-24' : '',
|
||||
inputClassName
|
||||
)}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
maxLength={6}
|
||||
autoFocus={!isMobile}
|
||||
value={amount ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAmountChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||
{error === 'Insufficient balance' ? (
|
||||
<>
|
||||
Not enough funds.
|
||||
<span className="ml-1 text-indigo-500">
|
||||
<SiteLink href="/add-funds">Buy more?</SiteLink>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -136,27 +144,29 @@ export function BuyAmountInput(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
{showSlider && (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="205"
|
||||
value={getRaw(amount ?? 0)}
|
||||
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
||||
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
|
||||
step="5"
|
||||
<Row className="gap-4">
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)}
|
||||
{showSlider && (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="205"
|
||||
value={getRaw(amount ?? 0)}
|
||||
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
||||
className="range range-lg only-thumb my-auto align-middle xl:hidden"
|
||||
step="5"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -182,17 +182,17 @@ export function AnswerBetPanel(props: {
|
|||
</Col>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
{user ? (
|
||||
<WarningConfirmationButton
|
||||
marketType="freeResponse"
|
||||
amount={betAmount}
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn self-stretch',
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||
isSubmitting ? 'loading' : ''
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -38,13 +38,26 @@ export function AnswersPanel(props: {
|
|||
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
||||
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||
)
|
||||
const [winningAnswers, notWinningAnswers] = partition(
|
||||
answers,
|
||||
(a) => a.id === resolution || (resolutions && resolutions[a.id])
|
||||
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||
|
||||
const [winningAnswers, losingAnswers] = partition(
|
||||
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
|
||||
(answer) =>
|
||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||
)
|
||||
const [visibleAnswers, invisibleAnswers] = partition(
|
||||
sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)),
|
||||
(a) => showAllAnswers || totalBets[a.id] > 0
|
||||
const sortedAnswers = [
|
||||
...sortBy(winningAnswers, (answer) =>
|
||||
resolutions ? -1 * resolutions[answer.id] : 0
|
||||
),
|
||||
...sortBy(
|
||||
resolution ? [] : losingAnswers,
|
||||
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||
),
|
||||
]
|
||||
|
||||
const answerItems = sortBy(
|
||||
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
|
||||
(answer) => -getOutcomeProbability(contract, answer.id)
|
||||
)
|
||||
|
||||
const user = useUser()
|
||||
|
@ -94,13 +107,13 @@ export function AnswersPanel(props: {
|
|||
return (
|
||||
<Col className="gap-3">
|
||||
{(resolveOption || resolution) &&
|
||||
sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => (
|
||||
sortedAnswers.map((answer) => (
|
||||
<AnswerItem
|
||||
key={a.id}
|
||||
answer={a}
|
||||
key={answer.id}
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
showChoice={showChoice}
|
||||
chosenProb={chosenAnswers[a.id]}
|
||||
chosenProb={chosenAnswers[answer.id]}
|
||||
totalChosenProb={chosenTotal}
|
||||
onChoose={onChoose}
|
||||
onDeselect={onDeselect}
|
||||
|
@ -114,10 +127,10 @@ export function AnswersPanel(props: {
|
|||
tradingAllowed(contract) ? '' : '-mb-6'
|
||||
)}
|
||||
>
|
||||
{visibleAnswers.map((a) => (
|
||||
<OpenAnswer key={a.id} answer={a} contract={contract} />
|
||||
{answerItems.map((item) => (
|
||||
<OpenAnswer key={item.id} answer={item} contract={contract} />
|
||||
))}
|
||||
{invisibleAnswers.length > 0 && !showAllAnswers && (
|
||||
{hasZeroBetAnswers && !showAllAnswers && (
|
||||
<Button
|
||||
className="self-end"
|
||||
color="gray-white"
|
||||
|
@ -130,7 +143,7 @@ export function AnswersPanel(props: {
|
|||
</Col>
|
||||
)}
|
||||
|
||||
{answers.length === 0 && (
|
||||
{answers.length <= 1 && (
|
||||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||
)}
|
||||
|
||||
|
|
|
@ -8,13 +8,14 @@ export function Avatar(props: {
|
|||
username?: string
|
||||
avatarUrl?: string
|
||||
noLink?: boolean
|
||||
size?: number | 'xs' | 'sm'
|
||||
size?: number | 'xxs' | 'xs' | 'sm'
|
||||
className?: string
|
||||
}) {
|
||||
const { username, noLink, size, className } = props
|
||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
const s =
|
||||
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
const sizeInPx = s * 4
|
||||
|
||||
const onClick =
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { SimpleBetPanel } from './bet-panel'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { BuyPanel, SimpleBetPanel } from './bet-panel'
|
||||
import {
|
||||
BinaryContract,
|
||||
CPMMBinaryContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import { Modal } from './layout/modal'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
|
@ -10,6 +14,9 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
|
|||
import { Col } from './layout/col'
|
||||
import { Button } from 'web/components/button'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { SellRow } from './sell-row'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -64,7 +71,6 @@ export default function BetButton(props: {
|
|||
<SimpleBetPanel
|
||||
className={betPanelClassName}
|
||||
contract={contract}
|
||||
selected="YES"
|
||||
onBetSuccess={() => setOpen(false)}
|
||||
hasShares={hasYesShares || hasNoShares}
|
||||
/>
|
||||
|
@ -72,3 +78,44 @@ export default function BetButton(props: {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function BinaryMobileBetting(props: { contract: BinaryContract }) {
|
||||
const { contract } = props
|
||||
const user = useUser()
|
||||
if (user) {
|
||||
return <SignedInBinaryMobileBetting contract={contract} user={user} />
|
||||
} else {
|
||||
return <BetSignUpPrompt className="w-full" />
|
||||
}
|
||||
}
|
||||
|
||||
export function SignedInBinaryMobileBetting(props: {
|
||||
contract: BinaryContract
|
||||
user: User
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col className="w-full gap-2 px-1">
|
||||
<Col>
|
||||
<BuyPanel
|
||||
hidden={false}
|
||||
contract={contract as CPMMBinaryContract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
mobileView={true}
|
||||
/>
|
||||
</Col>
|
||||
<SellRow
|
||||
contract={contract}
|
||||
user={user}
|
||||
className={
|
||||
'border-greyscale-3 bg-greyscale-1 rounded-md border-2 px-4 py-2'
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -43,6 +43,11 @@ import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
|||
import { isAndroid, isIOS } from 'web/lib/util/device'
|
||||
import { WarningConfirmationButton } from './warning-confirmation-button'
|
||||
import { MarketIntroPanel } from './market-intro-panel'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Title } from './title'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckIcon } from '@heroicons/react/solid'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -105,11 +110,10 @@ export function BetPanel(props: {
|
|||
export function SimpleBetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
selected?: 'YES' | 'NO'
|
||||
hasShares?: boolean
|
||||
onBetSuccess?: () => void
|
||||
}) {
|
||||
const { contract, className, selected, hasShares, onBetSuccess } = props
|
||||
const { contract, className, hasShares, onBetSuccess } = props
|
||||
|
||||
const user = useUser()
|
||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||
|
@ -139,7 +143,6 @@ export function SimpleBetPanel(props: {
|
|||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
selected={selected}
|
||||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
<LimitOrderPanel
|
||||
|
@ -162,38 +165,52 @@ export function SimpleBetPanel(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function BuyPanel(props: {
|
||||
export function BuyPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
unfilledBets: Bet[]
|
||||
hidden: boolean
|
||||
selected?: 'YES' | 'NO'
|
||||
onBuySuccess?: () => void
|
||||
mobileView?: boolean
|
||||
}) {
|
||||
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
|
||||
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
|
||||
props
|
||||
|
||||
const initialProb = getProbability(contract)
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||
const windowSize = useWindowSize()
|
||||
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 [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||
|
||||
const [inputRef, focusAmountInput] = useFocus()
|
||||
|
||||
function onBetChoice(choice: 'YES' | 'NO') {
|
||||
setOutcome(choice)
|
||||
setWasSubmitted(false)
|
||||
|
||||
if (!isIOS() && !isAndroid()) {
|
||||
focusAmountInput()
|
||||
}
|
||||
}
|
||||
|
||||
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
|
||||
if (outcome === choice) {
|
||||
setOutcome(undefined)
|
||||
} else {
|
||||
setOutcome(choice)
|
||||
}
|
||||
if (!isIOS() && !isAndroid()) {
|
||||
focusAmountInput()
|
||||
}
|
||||
}
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
setWasSubmitted(false)
|
||||
setBetAmount(newAmount)
|
||||
if (!outcome) {
|
||||
setOutcome('YES')
|
||||
|
@ -214,9 +231,13 @@ function BuyPanel(props: {
|
|||
.then((r) => {
|
||||
console.log('placed bet. Result:', r)
|
||||
setIsSubmitting(false)
|
||||
setWasSubmitted(true)
|
||||
setBetAmount(undefined)
|
||||
if (onBuySuccess) onBuySuccess()
|
||||
else {
|
||||
toast('Trade submitted!', {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e instanceof APIError) {
|
||||
|
@ -249,6 +270,7 @@ function BuyPanel(props: {
|
|||
unfilledBets as LimitBet[]
|
||||
)
|
||||
|
||||
const [seeLimit, setSeeLimit] = useState(false)
|
||||
const resultProb = getCpmmProbability(newPool, newP)
|
||||
const probStayedSame =
|
||||
formatPercent(resultProb) === formatPercent(initialProb)
|
||||
|
@ -281,92 +303,138 @@ function BuyPanel(props: {
|
|||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<div className="my-3 text-left text-sm text-gray-500">
|
||||
{isPseudoNumeric ? 'Direction' : 'Outcome'}
|
||||
</div>
|
||||
<YesNoSelector
|
||||
className="mb-4"
|
||||
btnClassName="flex-1"
|
||||
selected={outcome}
|
||||
onSelect={(choice) => onBetChoice(choice)}
|
||||
onSelect={(choice) => {
|
||||
if (mobileView) {
|
||||
mobileOnBetChoice(choice)
|
||||
} else {
|
||||
onBetChoice(choice)
|
||||
}
|
||||
}}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
/>
|
||||
|
||||
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||
Amount
|
||||
<span className={'xl:hidden'}>
|
||||
Balance: {formatMoney(user?.balance ?? 0)}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
onChange={onBetChange}
|
||||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||
</div>
|
||||
{probStayedSame ? (
|
||||
<div>{format(initialProb)}</div>
|
||||
) : (
|
||||
<div>
|
||||
{format(initialProb)}
|
||||
<span className="mx-2">→</span>
|
||||
{format(resultProb)}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row className="items-center justify-between gap-2 text-sm">
|
||||
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
||||
<div>
|
||||
{isPseudoNumeric ? (
|
||||
'Max payout'
|
||||
) : (
|
||||
<>
|
||||
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
{formatMoney(currentPayout)}
|
||||
</span>
|
||||
(+{currentReturnPercent})
|
||||
</div>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Spacer h={8} />
|
||||
|
||||
{user && (
|
||||
<WarningConfirmationButton
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
<Col
|
||||
className={clsx(
|
||||
mobileView
|
||||
? outcome === 'NO'
|
||||
? 'bg-red-25'
|
||||
: outcome === 'YES'
|
||||
? 'btn-primary'
|
||||
: 'border-none bg-red-400 hover:bg-red-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
? 'bg-teal-50'
|
||||
: 'hidden'
|
||||
: 'bg-white',
|
||||
mobileView ? 'rounded-lg px-4 py-2' : 'px-0'
|
||||
)}
|
||||
>
|
||||
<Row className="mt-3 w-full gap-3">
|
||||
<Col className="w-1/2 text-sm">
|
||||
<Col className="text-greyscale-4 flex-nowrap whitespace-nowrap text-xs">
|
||||
<div>
|
||||
{isPseudoNumeric ? (
|
||||
'Max payout'
|
||||
) : (
|
||||
<>Payout if {outcome ?? 'YES'}</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<div>
|
||||
<span className="whitespace-nowrap text-xl">
|
||||
{formatMoney(currentPayout)}
|
||||
</span>
|
||||
<span className="text-greyscale-4 text-xs">
|
||||
{' '}
|
||||
+{currentReturnPercent}
|
||||
</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col className="w-1/2 text-sm">
|
||||
<div className="text-greyscale-4 text-xs">
|
||||
{isPseudoNumeric ? 'Estimated value' : 'New Probability'}
|
||||
</div>
|
||||
{probStayedSame ? (
|
||||
<div className="text-xl">{format(initialProb)}</div>
|
||||
) : (
|
||||
<div className="text-xl">
|
||||
{format(resultProb)}
|
||||
<span className={clsx('text-greyscale-4 text-xs')}>
|
||||
{isPseudoNumeric ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
{outcome != 'NO' && '+'}
|
||||
{format(resultProb - initialProb)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="text-greyscale-4 mt-4 mb-1 justify-between text-left text-xs">
|
||||
Amount
|
||||
</Row>
|
||||
|
||||
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
onChange={onBetChange}
|
||||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
<Spacer h={8} />
|
||||
|
||||
{user && (
|
||||
<WarningConfirmationButton
|
||||
marketType="binary"
|
||||
amount={betAmount}
|
||||
outcome={outcome}
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
openModalButtonClass={clsx(
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled || outcome === undefined
|
||||
? 'btn-disabled bg-greyscale-2'
|
||||
: outcome === 'NO'
|
||||
? 'border-none bg-red-400 hover:bg-red-500'
|
||||
: 'border-none bg-teal-500 hover:bg-teal-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="text-greyscale-6 mx-auto select-none text-sm underline xl:hidden"
|
||||
onClick={() => setSeeLimit(true)}
|
||||
>
|
||||
Advanced
|
||||
</button>
|
||||
<Modal
|
||||
open={seeLimit}
|
||||
setOpen={setSeeLimit}
|
||||
position="center"
|
||||
className="rounded-lg bg-white px-4 pb-4"
|
||||
>
|
||||
<Title text="Limit Order" />
|
||||
<LimitOrderPanel
|
||||
hidden={!seeLimit}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
<LimitBets
|
||||
contract={contract}
|
||||
bets={unfilledBets as LimitBet[]}
|
||||
className="mt-4"
|
||||
/>
|
||||
</Modal>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -389,7 +457,6 @@ function LimitOrderPanel(props: {
|
|||
const betChoice = 'YES'
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||
|
||||
const rangeError =
|
||||
lowLimitProb !== undefined &&
|
||||
|
@ -437,7 +504,6 @@ function LimitOrderPanel(props: {
|
|||
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
setWasSubmitted(false)
|
||||
setBetAmount(newAmount)
|
||||
}
|
||||
|
||||
|
@ -482,7 +548,6 @@ function LimitOrderPanel(props: {
|
|||
.then((r) => {
|
||||
console.log('placed bet. Result:', r)
|
||||
setIsSubmitting(false)
|
||||
setWasSubmitted(true)
|
||||
setBetAmount(undefined)
|
||||
setLowLimitProb(undefined)
|
||||
setHighLimitProb(undefined)
|
||||
|
@ -718,8 +783,6 @@ function LimitOrderPanel(props: {
|
|||
: `Submit order${hasTwoBets ? 's' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -866,11 +929,7 @@ export function SellPanel(props: {
|
|||
<>
|
||||
<AmountInput
|
||||
amount={
|
||||
amount
|
||||
? Math.round(amount) === 0
|
||||
? 0
|
||||
: Math.floor(amount)
|
||||
: undefined
|
||||
amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
|
||||
}
|
||||
onChange={onAmountChange}
|
||||
label="Qty"
|
||||
|
|
120
web/components/bet-summary.tsx
Normal file
120
web/components/bet-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { Row } from './layout/row'
|
||||
import { sellBet } from 'web/lib/firebase/api'
|
||||
import { ConfirmationButton } from './confirmation-button'
|
||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
||||
import { OutcomeLabel } from './outcome-label'
|
||||
import { LoadingIndicator } from './loading-indicator'
|
||||
import { SiteLink } from './site-link'
|
||||
import {
|
||||
|
@ -38,14 +38,14 @@ import { NumericContract } from 'common/contract'
|
|||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUserBets } from 'web/hooks/use-user-bets'
|
||||
import { SellSharesModal } from './sell-modal'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { LimitBet } from 'common/bet'
|
||||
import { floatingEqual } from 'common/util/math'
|
||||
import { Pagination } from './pagination'
|
||||
import { LimitOrderTable } from './limit-bets'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
||||
import { BetsSummary } from './bet-summary'
|
||||
import { ProfitBadge } from './profit-badge'
|
||||
|
||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||
|
@ -77,7 +77,7 @@ export function BetsList(props: { user: User }) {
|
|||
}, [contractList])
|
||||
|
||||
const [sort, setSort] = useState<BetSort>('newest')
|
||||
const [filter, setFilter] = useState<BetFilter>('open')
|
||||
const [filter, setFilter] = useState<BetFilter>('all')
|
||||
const [page, setPage] = useState(0)
|
||||
const start = page * CONTRACTS_PER_PAGE
|
||||
const end = start + CONTRACTS_PER_PAGE
|
||||
|
@ -155,34 +155,25 @@ export function BetsList(props: { user: User }) {
|
|||
(c) => contractsMetrics[c.id].netPayout
|
||||
)
|
||||
|
||||
const totalPnl = user.profitCached.allTime
|
||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||
const investedProfitPercent =
|
||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
|
||||
<Row className="gap-8">
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Investment value</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(currentNetInvestment)}{' '}
|
||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Total profit</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(totalPnl)}{' '}
|
||||
<ProfitBadge profitPercent={totalProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="justify-between gap-4 sm:flex-row">
|
||||
<Col>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||
Investment value
|
||||
</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(currentNetInvestment)}{' '}
|
||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Row className="gap-8">
|
||||
<Row className="gap-2">
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as BetFilter)}
|
||||
>
|
||||
|
@ -195,7 +186,7 @@ export function BetsList(props: { user: User }) {
|
|||
</select>
|
||||
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as BetSort)}
|
||||
>
|
||||
|
@ -205,7 +196,7 @@ export function BetsList(props: { user: User }) {
|
|||
<option value="closeTime">Close date</option>
|
||||
</select>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Col className="mt-6 divide-y">
|
||||
{displayedContracts.length === 0 ? (
|
||||
|
@ -346,8 +337,7 @@ function ContractBets(props: {
|
|||
<BetsSummary
|
||||
className="mt-8 mr-5 flex-1 sm:mr-8"
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
userBets={bets}
|
||||
/>
|
||||
|
||||
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
||||
|
@ -373,125 +363,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: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
|
@ -610,18 +481,24 @@ function BetRow(props: {
|
|||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const saleAmount = saleBet?.sale?.amount
|
||||
// calculateSaleAmount is very slow right now so that's why we memoized this
|
||||
const payout = useMemo(() => {
|
||||
const saleBetAmount = saleBet?.sale?.amount
|
||||
if (saleBetAmount) {
|
||||
return saleBetAmount
|
||||
} else if (contract.isResolved) {
|
||||
return resolvedPayout(contract, bet)
|
||||
} else {
|
||||
return calculateSaleAmount(contract, bet, unfilledBets)
|
||||
}
|
||||
}, [contract, bet, saleBet, unfilledBets])
|
||||
|
||||
const saleDisplay = isAnte ? (
|
||||
'ANTE'
|
||||
) : saleAmount !== undefined ? (
|
||||
<>{formatMoney(saleAmount)} (sold)</>
|
||||
) : saleBet ? (
|
||||
<>{formatMoney(payout)} (sold)</>
|
||||
) : (
|
||||
formatMoney(
|
||||
isResolved
|
||||
? resolvedPayout(contract, bet)
|
||||
: calculateSaleAmount(contract, bet, unfilledBets)
|
||||
)
|
||||
formatMoney(payout)
|
||||
)
|
||||
|
||||
const payoutIfChosenDisplay =
|
||||
|
@ -753,30 +630,3 @@ function SellButton(props: {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
95
web/components/charts/contract/binary.tsx
Normal file
95
web/components/charts/contract/binary.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { useMemo, useRef } 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 { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import {
|
||||
MARGIN_X,
|
||||
MARGIN_Y,
|
||||
getDateRange,
|
||||
getRightmostVisibleDate,
|
||||
formatDateInRange,
|
||||
formatPct,
|
||||
} from '../helpers'
|
||||
import {
|
||||
SingleValueHistoryTooltipProps,
|
||||
SingleValueHistoryChart,
|
||||
} from '../generic-charts'
|
||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||
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,
|
||||
datum: b,
|
||||
}))
|
||||
}
|
||||
|
||||
const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => {
|
||||
const { p, xScale } = props
|
||||
const { x, y, datum } = p
|
||||
const [start, end] = xScale.domain()
|
||||
return (
|
||||
<Row className="items-center gap-2 text-sm">
|
||||
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />}
|
||||
<strong>{formatPct(y)}</strong>
|
||||
<span>{formatDateInRange(x, start, end)}</span>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export const BinaryContractChart = (props: {
|
||||
contract: BinaryContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) => {
|
||||
const { contract, bets } = props
|
||||
const [startDate, endDate] = getDateRange(contract)
|
||||
const startP = getInitialProbability(contract)
|
||||
const endP = getProbability(contract)
|
||||
const betPoints = useMemo(() => getBetPoints(bets), [bets])
|
||||
const data = useMemo(
|
||||
() => [
|
||||
{ x: startDate, y: startP },
|
||||
...betPoints,
|
||||
{ x: endDate ?? new Date(Date.now() + DAY_MS), y: endP },
|
||||
],
|
||||
[startDate, startP, endDate, endP, betPoints]
|
||||
)
|
||||
|
||||
const rightmostDate = getRightmostVisibleDate(
|
||||
endDate,
|
||||
last(betPoints)?.x,
|
||||
new Date(Date.now())
|
||||
)
|
||||
const visibleRange = [startDate, rightmostDate]
|
||||
const isMobile = useIsMobile(800)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const width = useElementWidth(containerRef) ?? 0
|
||||
const height = props.height ?? (isMobile ? 250 : 350)
|
||||
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
|
||||
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{width > 0 && (
|
||||
<SingleValueHistoryChart
|
||||
w={width}
|
||||
h={height}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
data={data}
|
||||
color="#11b981"
|
||||
Tooltip={BinaryChartTooltip}
|
||||
pct
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
206
web/components/charts/contract/choice.tsx
Normal file
206
web/components/charts/contract/choice.tsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { useMemo, useRef } 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 { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import {
|
||||
Legend,
|
||||
MARGIN_X,
|
||||
MARGIN_Y,
|
||||
getDateRange,
|
||||
getRightmostVisibleDate,
|
||||
formatPct,
|
||||
formatDateInRange,
|
||||
} from '../helpers'
|
||||
import {
|
||||
MultiPoint,
|
||||
MultiValueHistoryChart,
|
||||
MultiValueHistoryTooltipProps,
|
||||
} from '../generic-charts'
|
||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||
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),
|
||||
datum: bet,
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
export const ChoiceContractChart = (props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) => {
|
||||
const { contract, bets } = 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: start, y: answers.map((_) => 0) },
|
||||
...betPoints,
|
||||
{
|
||||
x: end ?? new Date(Date.now() + DAY_MS),
|
||||
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
|
||||
},
|
||||
],
|
||||
[answers, contract, betPoints, start, end]
|
||||
)
|
||||
const rightmostDate = getRightmostVisibleDate(
|
||||
end,
|
||||
last(betPoints)?.x,
|
||||
new Date(Date.now())
|
||||
)
|
||||
const visibleRange = [start, rightmostDate]
|
||||
const isMobile = useIsMobile(800)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const width = useElementWidth(containerRef) ?? 0
|
||||
const height = props.height ?? (isMobile ? 150 : 250)
|
||||
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
|
||||
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
||||
|
||||
const ChoiceTooltip = useMemo(
|
||||
() => (props: MultiValueHistoryTooltipProps<Bet>) => {
|
||||
const { p, xScale } = props
|
||||
const { x, y, datum } = p
|
||||
const [start, end] = xScale.domain()
|
||||
const legendItems = sortBy(
|
||||
y.map((p, i) => ({
|
||||
color: CATEGORY_COLORS[i],
|
||||
label: answers[i].text,
|
||||
value: formatPct(p),
|
||||
p,
|
||||
})),
|
||||
(item) => -item.p
|
||||
).slice(0, 10)
|
||||
return (
|
||||
<div>
|
||||
<Row className="items-center gap-2">
|
||||
{datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />}
|
||||
<span>{formatDateInRange(x, start, end)}</span>
|
||||
</Row>
|
||||
<Legend className="max-w-xs text-sm" items={legendItems} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[answers]
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{width > 0 && (
|
||||
<MultiValueHistoryChart
|
||||
w={width}
|
||||
h={height}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
data={data}
|
||||
colors={CATEGORY_COLORS}
|
||||
Tooltip={ChoiceTooltip}
|
||||
pct
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
34
web/components/charts/contract/index.tsx
Normal file
34
web/components/charts/contract/index.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
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[]
|
||||
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,
|
||||
}
|
66
web/components/charts/contract/numeric.tsx
Normal file
66
web/components/charts/contract/numeric.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { useMemo, useRef } 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 { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import { MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
|
||||
import {
|
||||
SingleValueDistributionChart,
|
||||
SingleValueDistributionTooltipProps,
|
||||
} from '../generic-charts'
|
||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||
|
||||
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: SingleValueDistributionTooltipProps) => {
|
||||
const { p } = props
|
||||
const { x, y } = p
|
||||
return (
|
||||
<span className="text-sm">
|
||||
<strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const NumericContractChart = (props: {
|
||||
contract: NumericContract
|
||||
height?: number
|
||||
}) => {
|
||||
const { contract } = props
|
||||
const { min, max } = contract
|
||||
const data = useMemo(() => getNumericChartData(contract), [contract])
|
||||
const isMobile = useIsMobile(800)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const width = useElementWidth(containerRef) ?? 0
|
||||
const height = props.height ?? (isMobile ? 150 : 250)
|
||||
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 (
|
||||
<div ref={containerRef}>
|
||||
{width > 0 && (
|
||||
<SingleValueDistributionChart
|
||||
w={width}
|
||||
h={height}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
data={data}
|
||||
color={NUMERIC_GRAPH_COLOR}
|
||||
Tooltip={NumericChartTooltip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
115
web/components/charts/contract/pseudo-numeric.tsx
Normal file
115
web/components/charts/contract/pseudo-numeric.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { useMemo, useRef } 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 { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import {
|
||||
MARGIN_X,
|
||||
MARGIN_Y,
|
||||
getDateRange,
|
||||
getRightmostVisibleDate,
|
||||
formatDateInRange,
|
||||
} from '../helpers'
|
||||
import {
|
||||
SingleValueHistoryChart,
|
||||
SingleValueHistoryTooltipProps,
|
||||
} from '../generic-charts'
|
||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||
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),
|
||||
datum: b,
|
||||
}))
|
||||
}
|
||||
|
||||
const PseudoNumericChartTooltip = (
|
||||
props: SingleValueHistoryTooltipProps<Bet>
|
||||
) => {
|
||||
const { p, xScale } = props
|
||||
const { x, y, datum } = p
|
||||
const [start, end] = xScale.domain()
|
||||
return (
|
||||
<Row className="items-center gap-2 text-sm">
|
||||
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />}
|
||||
<strong>{formatLargeNumber(y)}</strong>
|
||||
<span>{formatDateInRange(x, start, end)}</span>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export const PseudoNumericContractChart = (props: {
|
||||
contract: PseudoNumericContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) => {
|
||||
const { contract, bets } = props
|
||||
const { min, max, isLogScale } = contract
|
||||
const [startDate, endDate] = 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: startDate, y: startP },
|
||||
...betPoints,
|
||||
{ x: endDate ?? new Date(Date.now() + DAY_MS), y: endP },
|
||||
],
|
||||
[betPoints, startDate, startP, endDate, endP]
|
||||
)
|
||||
const rightmostDate = getRightmostVisibleDate(
|
||||
endDate,
|
||||
last(betPoints)?.x,
|
||||
new Date(Date.now())
|
||||
)
|
||||
const visibleRange = [startDate, rightmostDate]
|
||||
const isMobile = useIsMobile(800)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const width = useElementWidth(containerRef) ?? 0
|
||||
const height = props.height ?? (isMobile ? 150 : 250)
|
||||
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
|
||||
// clamp log scale to make sure zeroes go to the bottom
|
||||
const yScale = isLogScale
|
||||
? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true)
|
||||
: scaleLinear([min, max], [height - MARGIN_Y, 0])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{width > 0 && (
|
||||
<SingleValueHistoryChart
|
||||
w={width}
|
||||
h={height}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
data={data}
|
||||
Tooltip={PseudoNumericChartTooltip}
|
||||
color={NUMERIC_GRAPH_COLOR}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
280
web/components/charts/generic-charts.tsx
Normal file
280
web/components/charts/generic-charts.tsx
Normal file
|
@ -0,0 +1,280 @@
|
|||
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 {
|
||||
SVGChart,
|
||||
AreaPath,
|
||||
AreaWithTopStroke,
|
||||
TooltipContent,
|
||||
formatPct,
|
||||
} from './helpers'
|
||||
import { useEvent } from 'web/hooks/use-event'
|
||||
|
||||
export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T }
|
||||
export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T }
|
||||
export type DistributionPoint<T = never> = { x: number; y: number; datum?: 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]
|
||||
}
|
||||
|
||||
export const SingleValueDistributionChart = <T,>(props: {
|
||||
data: DistributionPoint<T>[]
|
||||
w: number
|
||||
h: number
|
||||
color: string
|
||||
xScale: ScaleContinuousNumeric<number, number>
|
||||
yScale: ScaleContinuousNumeric<number, number>
|
||||
Tooltip?: TooltipContent<SingleValueDistributionTooltipProps<T>>
|
||||
}) => {
|
||||
const { color, data, yScale, w, h, Tooltip } = props
|
||||
|
||||
const [viewXScale, setViewXScale] =
|
||||
useState<ScaleContinuousNumeric<number, number>>()
|
||||
const xScale = viewXScale ?? props.xScale
|
||||
|
||||
const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale])
|
||||
const py0 = yScale(yScale.domain()[0])
|
||||
const py1 = useCallback((p: DistributionPoint<T>) => yScale(p.y), [yScale])
|
||||
const xBisector = bisector((p: DistributionPoint<T>) => p.x)
|
||||
|
||||
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 onSelect = useEvent((ev: D3BrushEvent<DistributionPoint<T>>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
setViewXScale(() =>
|
||||
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
||||
)
|
||||
} else {
|
||||
setViewXScale(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const onMouseOver = useEvent((mouseX: number) => {
|
||||
const queryX = xScale.invert(mouseX)
|
||||
const item = data[xBisector.left(data, queryX) - 1]
|
||||
if (item == null) {
|
||||
// this can happen if you are on the very left or right edge of the chart,
|
||||
// so your queryX is out of bounds
|
||||
return
|
||||
}
|
||||
return { x: queryX, y: item.y, datum: item.datum }
|
||||
})
|
||||
|
||||
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 type SingleValueDistributionTooltipProps<T = unknown> = {
|
||||
p: DistributionPoint<T>
|
||||
xScale: React.ComponentProps<typeof SingleValueDistributionChart<T>>['xScale']
|
||||
}
|
||||
|
||||
export const MultiValueHistoryChart = <T,>(props: {
|
||||
data: MultiPoint<T>[]
|
||||
w: number
|
||||
h: number
|
||||
colors: readonly string[]
|
||||
xScale: ScaleTime<number, number>
|
||||
yScale: ScaleContinuousNumeric<number, number>
|
||||
Tooltip?: TooltipContent<MultiValueHistoryTooltipProps<T>>
|
||||
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<MultiPoint<T>>
|
||||
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 xBisector = bisector((p: MultiPoint<T>) => p.x)
|
||||
|
||||
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<MultiPoint<T>, number>()
|
||||
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
|
||||
.value(({ y }, o) => y[o])
|
||||
.order(stackOrderReverse)
|
||||
return d3Stack(data)
|
||||
}, [data])
|
||||
|
||||
const onSelect = useEvent((ev: D3BrushEvent<MultiPoint<T>>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
setViewXScale(() =>
|
||||
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
||||
)
|
||||
} else {
|
||||
setViewXScale(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const onMouseOver = useEvent((mouseX: number) => {
|
||||
const queryX = xScale.invert(mouseX)
|
||||
const item = data[xBisector.left(data, queryX) - 1]
|
||||
if (item == null) {
|
||||
// this can happen if you are on the very left or right edge of the chart,
|
||||
// so your queryX is out of bounds
|
||||
return
|
||||
}
|
||||
return { x: queryX, y: item.y, datum: item.datum }
|
||||
})
|
||||
|
||||
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 type MultiValueHistoryTooltipProps<T = unknown> = {
|
||||
p: MultiPoint<T>
|
||||
xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale']
|
||||
}
|
||||
|
||||
export const SingleValueHistoryChart = <T,>(props: {
|
||||
data: HistoryPoint<T>[]
|
||||
w: number
|
||||
h: number
|
||||
color: string
|
||||
xScale: ScaleTime<number, number>
|
||||
yScale: ScaleContinuousNumeric<number, number>
|
||||
Tooltip?: TooltipContent<SingleValueHistoryTooltipProps<T>>
|
||||
pct?: boolean
|
||||
}) => {
|
||||
const { color, data, pct, yScale, w, h, Tooltip } = props
|
||||
|
||||
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
||||
const xScale = viewXScale ?? props.xScale
|
||||
|
||||
const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale])
|
||||
const py0 = yScale(yScale.domain()[0])
|
||||
const py1 = useCallback((p: HistoryPoint<T>) => yScale(p.y), [yScale])
|
||||
const xBisector = bisector((p: HistoryPoint<T>) => p.x)
|
||||
|
||||
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 onSelect = useEvent((ev: D3BrushEvent<HistoryPoint<T>>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
setViewXScale(() =>
|
||||
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
|
||||
)
|
||||
} else {
|
||||
setViewXScale(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const onMouseOver = useEvent((mouseX: number) => {
|
||||
const queryX = xScale.invert(mouseX)
|
||||
const item = data[xBisector.left(data, queryX) - 1]
|
||||
if (item == null) {
|
||||
// this can happen if you are on the very left or right edge of the chart,
|
||||
// so your queryX is out of bounds
|
||||
return
|
||||
}
|
||||
return { x: queryX, y: item.y, datum: item.datum }
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export type SingleValueHistoryTooltipProps<T = unknown> = {
|
||||
p: HistoryPoint<T>
|
||||
xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale']
|
||||
}
|
316
web/components/charts/helpers.tsx
Normal file
316
web/components/charts/helpers.tsx
Normal file
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
ReactNode,
|
||||
SVGProps,
|
||||
memo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { pointer, select } from 'd3-selection'
|
||||
import { Axis } 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 { Row } from 'web/components/layout/row'
|
||||
|
||||
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
|
||||
|
||||
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.3}
|
||||
/>
|
||||
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export const SVGChart = <X, Y, P, XS>(props: {
|
||||
children: ReactNode
|
||||
w: number
|
||||
h: number
|
||||
xAxis: Axis<X>
|
||||
yAxis: Axis<Y>
|
||||
onSelect?: (ev: D3BrushEvent<any>) => void
|
||||
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined
|
||||
Tooltip?: TooltipContent<{ xScale: XS } & { p: P }>
|
||||
}) => {
|
||||
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
|
||||
const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>()
|
||||
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)
|
||||
setMouseState(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 [mouseX, mouseY] = pointer(ev)
|
||||
const p = onMouseOver(mouseX, mouseY)
|
||||
if (p != null) {
|
||||
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
|
||||
} else {
|
||||
setMouseState(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerLeave = () => {
|
||||
setMouseState(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{mouseState && Tooltip && (
|
||||
<TooltipContainer top={mouseState.top} left={mouseState.left}>
|
||||
<Tooltip xScale={xAxis.scale() as XS} p={mouseState.p} />
|
||||
</TooltipContainer>
|
||||
)}
|
||||
<svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||||
<clipPath id={clipPathId}>
|
||||
<rect x={0} y={0} width={innerW} height={innerH} />
|
||||
</clipPath>
|
||||
<g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
|
||||
<XAxis axis={xAxis} w={innerW} h={innerH} />
|
||||
<YAxis axis={yAxis} w={innerW} h={innerH} />
|
||||
<g clipPath={`url(#${clipPathId})`}>{children}</g>
|
||||
<g
|
||||
ref={overlayRef}
|
||||
x="0"
|
||||
y="0"
|
||||
width={innerW}
|
||||
height={innerH}
|
||||
fill="none"
|
||||
pointerEvents="all"
|
||||
onPointerEnter={onPointerMove}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={onPointerLeave}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type TooltipContent<P> = React.ComponentType<P>
|
||||
export type TooltipPosition = { top: number; left: number }
|
||||
export const TooltipContainer = (
|
||||
props: TooltipPosition & { className?: string; children: React.ReactNode }
|
||||
) => {
|
||||
const { top, left, className, children } = props
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2'
|
||||
)}
|
||||
style={{ top, left }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LegendItem = { color: string; label: string; value?: string }
|
||||
export 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">
|
||||
<Row className="mr-2 items-center overflow-hidden">
|
||||
<span
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
></span>
|
||||
<span className="overflow-hidden text-ellipsis">{item.label}</span>
|
||||
</Row>
|
||||
{item.value}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export const getDateRange = (contract: Contract) => {
|
||||
const { createdTime, closeTime, resolutionTime } = contract
|
||||
const isClosed = !!closeTime && Date.now() > closeTime
|
||||
const endDate = resolutionTime ?? (isClosed ? closeTime : null)
|
||||
return [new Date(createdTime), endDate ? new Date(endDate) : null] as const
|
||||
}
|
||||
|
||||
export const getRightmostVisibleDate = (
|
||||
contractEnd: Date | null | undefined,
|
||||
lastActivity: Date | null | undefined,
|
||||
now: Date
|
||||
) => {
|
||||
if (contractEnd != null) {
|
||||
return contractEnd
|
||||
} else if (lastActivity != null) {
|
||||
// client-DB clock divergence may cause last activity to be later than now
|
||||
return new Date(Math.max(lastActivity.getTime(), now.getTime()))
|
||||
} 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)
|
||||
}
|
|
@ -103,6 +103,7 @@ export function ContractSearch(props: {
|
|||
loadMore: () => void
|
||||
) => ReactNode
|
||||
autoFocus?: boolean
|
||||
profile?: boolean | undefined
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -123,6 +124,7 @@ export function ContractSearch(props: {
|
|||
maxResults,
|
||||
renderContracts,
|
||||
autoFocus,
|
||||
profile,
|
||||
} = props
|
||||
|
||||
const [state, setState] = usePersistentState(
|
||||
|
@ -239,6 +241,10 @@ export function ContractSearch(props: {
|
|||
/>
|
||||
{renderContracts ? (
|
||||
renderContracts(renderedContracts, performQuery)
|
||||
) : renderedContracts && renderedContracts.length === 0 && profile ? (
|
||||
<p className="mx-2 text-gray-500">
|
||||
This creator does not yet have any markets.
|
||||
</p>
|
||||
) : (
|
||||
<ContractsGrid
|
||||
contracts={renderedContracts}
|
||||
|
|
|
@ -2,7 +2,12 @@ import React from 'react'
|
|||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { ContractProbGraph } from './contract-prob-graph'
|
||||
import {
|
||||
BinaryContractChart,
|
||||
NumericContractChart,
|
||||
PseudoNumericContractChart,
|
||||
ChoiceContractChart,
|
||||
} from 'web/components/charts/contract'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from '../layout/row'
|
||||
import { Linkify } from '../linkify'
|
||||
|
@ -13,20 +18,17 @@ import {
|
|||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
import { Bet } from 'common/bet'
|
||||
import BetButton from '../bet-button'
|
||||
import { AnswersGraph } from '../answers/answers-graph'
|
||||
import BetButton, { BinaryMobileBetting } from '../bet-button'
|
||||
import {
|
||||
Contract,
|
||||
BinaryContract,
|
||||
CPMMContract,
|
||||
CPMMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
BinaryContract,
|
||||
} from 'common/contract'
|
||||
import { ContractDetails } from './contract-details'
|
||||
import { NumericGraph } from './numeric-graph'
|
||||
|
||||
const OverviewQuestion = (props: { text: string }) => (
|
||||
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
|
||||
|
@ -64,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => {
|
|||
contract={contract}
|
||||
/>
|
||||
</Col>
|
||||
<NumericGraph contract={contract} />
|
||||
<NumericContractChart contract={contract} />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -78,19 +80,18 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
|||
<Row className="justify-between gap-4">
|
||||
<OverviewQuestion text={contract.question} />
|
||||
<BinaryResolutionOrChance
|
||||
className="hidden items-end xl:flex"
|
||||
className="flex items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
</Row>
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
{tradingAllowed(contract) && (
|
||||
<BetWidget contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
|
||||
<BinaryContractChart contract={contract} bets={bets} />
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
{tradingAllowed(contract) && (
|
||||
<BinaryMobileBetting contract={contract} />
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -111,7 +112,7 @@ const ChoiceOverview = (props: {
|
|||
)}
|
||||
</Col>
|
||||
<Col className={'mb-1 gap-y-2'}>
|
||||
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
|
||||
<ChoiceContractChart contract={contract} bets={bets} />
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
|
@ -138,7 +139,7 @@ const PseudoNumericOverview = (props: {
|
|||
{tradingAllowed(contract) && <BetWidget contract={contract} />}
|
||||
</Row>
|
||||
</Col>
|
||||
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
|
||||
<PseudoNumericContractChart contract={contract} bets={bets} />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -9,7 +9,7 @@ import { groupBy, sortBy } from 'lodash'
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract } from 'common/contract'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||
import { ContractBetsTable } from '../bets-list'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Tabs } from '../layout/tabs'
|
||||
import { Col } from '../layout/col'
|
||||
|
@ -17,68 +17,57 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
|||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { capitalize } from 'lodash'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} 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[] }) {
|
||||
const { contract, bets } = props
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const user = useUser()
|
||||
const userBets =
|
||||
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
userBets: Bet[]
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract, bets, userBets, comments } = props
|
||||
|
||||
const yourTrades = (
|
||||
<div>
|
||||
<BetsSummary
|
||||
className="px-2"
|
||||
contract={contract}
|
||||
bets={userBets ?? []}
|
||||
isYourBets
|
||||
/>
|
||||
<Spacer h={6} />
|
||||
<ContractBetsTable contract={contract} bets={userBets ?? []} isYourBets />
|
||||
<ContractBetsTable contract={contract} bets={userBets} isYourBets />
|
||||
<Spacer h={12} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const tabs = buildArray(
|
||||
{
|
||||
title: 'Comments',
|
||||
content: <CommentsTabContent contract={contract} comments={comments} />,
|
||||
},
|
||||
{
|
||||
title: capitalize(PAST_BETS),
|
||||
content: <BetsTabContent contract={contract} bets={bets} />,
|
||||
},
|
||||
userBets.length > 0 && {
|
||||
title: 'Your trades',
|
||||
content: yourTrades,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<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,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<Tabs className="mb-4" currentPageForAnalytics={'contract'} tabs={tabs} />
|
||||
)
|
||||
}
|
||||
|
||||
const CommentsTabContent = memo(function CommentsTabContent(props: {
|
||||
contract: Contract
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract } = props
|
||||
const tips = useTipTxns({ contractId: contract.id })
|
||||
const comments = useComments(contract.id)
|
||||
const comments = useComments(contract.id) ?? props.comments
|
||||
if (comments == null) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
|
|
@ -128,6 +128,7 @@ export function CreatorContractsList(props: {
|
|||
creatorId: creator.id,
|
||||
}}
|
||||
persistPrefix={`user-${creator.id}`}
|
||||
profile={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { sortBy } from 'lodash'
|
||||
import clsx from 'clsx'
|
||||
import { partition } from 'lodash'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
|
@ -7,6 +7,7 @@ import { SiteLink } from '../site-link'
|
|||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
||||
export function ProbChangeTable(props: {
|
||||
changes: CPMMContract[] | undefined
|
||||
|
@ -16,16 +17,14 @@ export function ProbChangeTable(props: {
|
|||
|
||||
if (!changes) return <LoadingIndicator />
|
||||
|
||||
const [positiveChanges, negativeChanges] = partition(
|
||||
changes,
|
||||
(c) => c.probChanges.day > 0
|
||||
)
|
||||
const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse()
|
||||
const ascendingChanges = sortBy(changes, (c) => c.probChanges.day)
|
||||
|
||||
const threshold = 0.01
|
||||
const positiveAboveThreshold = positiveChanges.filter(
|
||||
const positiveAboveThreshold = descendingChanges.filter(
|
||||
(c) => c.probChanges.day > threshold
|
||||
)
|
||||
const negativeAboveThreshold = negativeChanges.filter(
|
||||
const negativeAboveThreshold = ascendingChanges.filter(
|
||||
(c) => c.probChanges.day < threshold
|
||||
)
|
||||
const maxRows = Math.min(
|
||||
|
@ -59,7 +58,9 @@ export function ProbChangeRow(props: {
|
|||
contract: CPMMContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const { className } = props
|
||||
const contract =
|
||||
(useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
|
||||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
|
|
|
@ -344,7 +344,7 @@ export function getColor(contract: Contract) {
|
|||
return (
|
||||
OUTCOME_TO_COLOR[resolution as resolution] ??
|
||||
// If resolved to a FR answer, use 'primary'
|
||||
'primary'
|
||||
'teal-500'
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -355,5 +355,5 @@ export function getColor(contract: Contract) {
|
|||
}
|
||||
|
||||
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
|
||||
return 'primary'
|
||||
return 'teal-500'
|
||||
}
|
||||
|
|
|
@ -13,16 +13,18 @@ import { useDiscoverUsers } from 'web/hooks/use-users'
|
|||
import { TextButton } from './text-button'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function FollowingButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function FollowingButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const followingIds = useFollows(user.id)
|
||||
const followerIds = useFollowers(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<span className="font-semibold">{followingIds?.length ?? ''}</span>{' '}
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className={clsx('font-semibold')}>
|
||||
{followingIds?.length ?? ''}
|
||||
</span>{' '}
|
||||
Following
|
||||
</TextButton>
|
||||
|
||||
|
@ -69,15 +71,15 @@ export function EditFollowingButton(props: { user: User; className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function FollowersButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function FollowersButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const followingIds = useFollows(user.id)
|
||||
const followerIds = useFollowers(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{followerIds?.length ?? ''}</span>{' '}
|
||||
Followers
|
||||
</TextButton>
|
||||
|
|
|
@ -14,14 +14,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
|
|||
import { GroupLinkItem } from 'web/pages/groups'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export function GroupsButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function GroupsButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const groups = useMemberGroups(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
|
||||
</TextButton>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
|||
import { useRouter, NextRouter } from 'next/router'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Col } from './col'
|
||||
|
||||
type Tab = {
|
||||
title: string
|
||||
|
@ -55,11 +56,13 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
|||
)}
|
||||
aria-current={activeIndex === i ? 'page' : undefined}
|
||||
>
|
||||
{tab.tabIcon && <span>{tab.tabIcon}</span>}
|
||||
{tab.badge ? (
|
||||
<span className="px-0.5 font-bold">{tab.badge}</span>
|
||||
) : null}
|
||||
{tab.title}
|
||||
<Col>
|
||||
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>}
|
||||
{tab.title}
|
||||
</Col>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
@ -20,8 +20,6 @@ import { useIsIframe } from 'web/hooks/use-is-iframe'
|
|||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { User } from 'common/user'
|
||||
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
|
@ -42,7 +40,7 @@ const signedOutNavigation = [
|
|||
export const userProfileItem = (user: User) => ({
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=${PAST_BETS}`,
|
||||
href: `/${user.username}?tab=portfolio`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
|
|
|
@ -4,12 +4,11 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}?tab=${PAST_BETS}`}>
|
||||
<Link href={`/${user.username}?tab=portfolio`}>
|
||||
<a
|
||||
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"
|
||||
|
|
|
@ -164,6 +164,7 @@ function getMoreDesktopNavigation(user?: User | null) {
|
|||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Dating docs', href: '/date-docs' },
|
||||
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
|
@ -226,6 +227,7 @@ function getMoreMobileNav() {
|
|||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Dating docs', href: '/date-docs' },
|
||||
],
|
||||
signOut
|
||||
)
|
||||
|
|
|
@ -106,7 +106,7 @@ export const OUTCOME_TO_COLOR = {
|
|||
}
|
||||
|
||||
export function YesLabel() {
|
||||
return <span className="text-primary">YES</span>
|
||||
return <span className="text-teal-500">YES</span>
|
||||
}
|
||||
|
||||
export function HigherLabel() {
|
||||
|
|
|
@ -1,72 +1,155 @@
|
|||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import dayjs from 'dayjs'
|
||||
import { last } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { formatTime } from 'web/lib/util/time'
|
||||
import { Col } from '../layout/col'
|
||||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
mode: 'value' | 'profit'
|
||||
handleGraphDisplayChange: (arg0: string | number | null) => void
|
||||
height?: number
|
||||
includeTime?: boolean
|
||||
}) {
|
||||
const { portfolioHistory, height, includeTime, mode } = props
|
||||
const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
const profit = value - totalDeposits
|
||||
const valuePoints = getPoints('value', portfolioHistory)
|
||||
const posProfitPoints = getPoints('posProfit', portfolioHistory)
|
||||
const negProfitPoints = getPoints('negProfit', portfolioHistory)
|
||||
|
||||
const valuePointsY = valuePoints.map((p) => p.y)
|
||||
const posProfitPointsY = posProfitPoints.map((p) => p.y)
|
||||
const negProfitPointsY = negProfitPoints.map((p) => p.y)
|
||||
|
||||
let data
|
||||
|
||||
if (mode === 'value') {
|
||||
data = [{ id: 'value', data: valuePoints, color: '#4f46e5' }]
|
||||
} else {
|
||||
data = [
|
||||
{
|
||||
id: 'negProfit',
|
||||
data: negProfitPoints,
|
||||
color: '#dc2626',
|
||||
},
|
||||
{
|
||||
id: 'posProfit',
|
||||
data: posProfitPoints,
|
||||
color: '#14b8a6',
|
||||
},
|
||||
]
|
||||
}
|
||||
const numYTickValues = 2
|
||||
const endDate = last(data[0].data)?.x
|
||||
|
||||
const yMin =
|
||||
mode === 'value'
|
||||
? Math.min(...filterDefined(valuePointsY))
|
||||
: Math.min(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
|
||||
const yMax =
|
||||
mode === 'value'
|
||||
? Math.max(...filterDefined(valuePointsY))
|
||||
: Math.max(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
|
||||
return {
|
||||
x: new Date(timestamp),
|
||||
y: mode === 'value' ? value : profit,
|
||||
}
|
||||
})
|
||||
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const numYTickValues = 4
|
||||
const endDate = last(points)?.x
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
|
||||
style={{ height: height ?? (!width || width >= 800 ? 200 : 100) }}
|
||||
onMouseLeave={() => handleGraphDisplayChange(null)}
|
||||
>
|
||||
<ResponsiveLine
|
||||
margin={{ top: 10, right: 0, left: 40, bottom: 10 }}
|
||||
data={data}
|
||||
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: points[0]?.x,
|
||||
min: valuePoints[0]?.x,
|
||||
max: endDate,
|
||||
}}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
stacked: false,
|
||||
min: Math.min(...points.map((p) => p.y)),
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
}}
|
||||
gridYValues={numYTickValues}
|
||||
curve="stepAfter"
|
||||
enablePoints={false}
|
||||
colors={{ datum: 'color' }}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, !!includeTime),
|
||||
tickValues: 0,
|
||||
}}
|
||||
pointBorderColor="#fff"
|
||||
pointSize={points.length > 100 ? 0 : 6}
|
||||
pointSize={valuePoints.length > 100 ? 0 : 6}
|
||||
axisLeft={{
|
||||
tickValues: numYTickValues,
|
||||
format: (value) => formatMoney(value),
|
||||
format: '.3s',
|
||||
}}
|
||||
enableGridX={!!width && width >= 800}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
gridYValues={numYTickValues}
|
||||
enableSlices="x"
|
||||
animate={false}
|
||||
yFormat={(value) => formatMoney(+value)}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
sliceTooltip={({ slice }) => {
|
||||
handleGraphDisplayChange(slice.points[0].data.yFormatted)
|
||||
return (
|
||||
<div className="rounded bg-white px-4 py-2 opacity-80">
|
||||
<div
|
||||
key={slice.points[0].id}
|
||||
className="text-xs font-semibold sm:text-sm"
|
||||
>
|
||||
<Col>
|
||||
<div>
|
||||
{dayjs(slice.points[0].data.xFormatted).format('MMM/D/YY')}
|
||||
</div>
|
||||
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
|
||||
{dayjs(slice.points[0].data.xFormatted).format('h:mm A')}
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{/* ))} */}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
></ResponsiveLine>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function getPoints(
|
||||
line: 'value' | 'posProfit' | 'negProfit',
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
) {
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
|
||||
const profit = value - totalDeposits
|
||||
let posProfit = null
|
||||
let negProfit = null
|
||||
if (profit < 0) {
|
||||
negProfit = profit
|
||||
} else {
|
||||
posProfit = profit
|
||||
}
|
||||
|
||||
return {
|
||||
x: new Date(timestamp),
|
||||
y:
|
||||
line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit,
|
||||
}
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { last } from 'lodash'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { Period } from 'web/lib/firebase/users'
|
||||
import { PillButton } from '../buttons/pill-button'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
|
@ -14,6 +15,13 @@ export const PortfolioValueSection = memo(
|
|||
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
|
||||
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value')
|
||||
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
|
||||
number | string | null
|
||||
>(null)
|
||||
const handleGraphDisplayChange = (num: string | number | null) => {
|
||||
setGraphDisplayNumber(num)
|
||||
}
|
||||
|
||||
// Remember the last defined portfolio history.
|
||||
const portfolioRef = useRef(portfolioHistory)
|
||||
|
@ -28,43 +36,144 @@ export const PortfolioValueSection = memo(
|
|||
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
|
||||
const totalValue = balance + investmentValue
|
||||
const totalProfit = totalValue - totalDeposits
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="gap-8">
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Profit</div>
|
||||
<div className="text-lg">{formatMoney(totalProfit)}</div>
|
||||
</Col>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
value={portfolioPeriod}
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="monthly">Last Month</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
<option value="daily">Last 24h</option>
|
||||
</select>
|
||||
<Row className="mb-2 justify-between">
|
||||
<Row className="gap-4 sm:gap-8">
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer',
|
||||
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
|
||||
)}
|
||||
onClick={() => setGraphMode('value')}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||
Portfolio value
|
||||
</div>
|
||||
<div className={clsx('text-lg text-indigo-600 sm:text-xl')}>
|
||||
{graphMode === 'value'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber
|
||||
: formatMoney(totalValue)
|
||||
: formatMoney(totalValue)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer',
|
||||
graphMode != 'profit'
|
||||
? 'cursor-pointer opacity-40 hover:opacity-80'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => setGraphMode('profit')}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">Profit</div>
|
||||
<div
|
||||
className={clsx(
|
||||
graphMode === 'profit'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber.toString().includes('-')
|
||||
? 'text-red-600'
|
||||
: 'text-teal-500'
|
||||
: totalProfit > 0
|
||||
? 'text-teal-500'
|
||||
: 'text-red-600'
|
||||
: totalProfit > 0
|
||||
? 'text-teal-500'
|
||||
: 'text-red-600',
|
||||
'text-lg sm:text-xl'
|
||||
)}
|
||||
>
|
||||
{graphMode === 'profit'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber
|
||||
: formatMoney(totalProfit)
|
||||
: formatMoney(totalProfit)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="profit"
|
||||
mode={graphMode}
|
||||
handleGraphDisplayChange={handleGraphDisplayChange}
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
</Col>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="value"
|
||||
<PortfolioPeriodSelection
|
||||
portfolioPeriod={portfolioPeriod}
|
||||
setPortfolioPeriod={setPortfolioPeriod}
|
||||
className="border-greyscale-2 mt-2 gap-4 border-b"
|
||||
selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export function PortfolioPeriodSelection(props: {
|
||||
setPortfolioPeriod: (string: any) => void
|
||||
portfolioPeriod: string
|
||||
className?: string
|
||||
selectClassName?: string
|
||||
}) {
|
||||
const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } =
|
||||
props
|
||||
return (
|
||||
<Row className={clsx(className, 'text-greyscale-4')}>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('daily' as Period)}
|
||||
>
|
||||
1D
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('weekly' as Period)}
|
||||
>
|
||||
1W
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('monthly' as Period)}
|
||||
>
|
||||
1M
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('allTime' as Period)}
|
||||
>
|
||||
ALL
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function GraphToggle(props: {
|
||||
setGraphMode: (mode: 'profit' | 'value') => void
|
||||
graphMode: string
|
||||
}) {
|
||||
const { setGraphMode, graphMode } = props
|
||||
return (
|
||||
<Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<PillButton
|
||||
selected={graphMode === 'value'}
|
||||
onSelect={() => {
|
||||
setGraphMode('value')
|
||||
}}
|
||||
xs={true}
|
||||
className="z-50"
|
||||
>
|
||||
Value
|
||||
</PillButton>
|
||||
<PillButton
|
||||
selected={graphMode === 'profit'}
|
||||
onSelect={() => {
|
||||
setGraphMode('profit')
|
||||
}}
|
||||
xs={true}
|
||||
className="z-50"
|
||||
>
|
||||
Profit
|
||||
</PillButton>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,15 +10,15 @@ import { XIcon } from '@heroicons/react/outline'
|
|||
import { unLikeContract } from 'web/lib/firebase/likes'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
|
||||
export function UserLikesButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function UserLikesButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const likedContracts = useUserLikedContracts(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '}
|
||||
Likes
|
||||
</TextButton>
|
||||
|
|
28
web/components/profit-badge.tsx
Normal file
28
web/components/profit-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -13,14 +13,18 @@ import { getUser, updateUser } from 'web/lib/firebase/users'
|
|||
import { TextButton } from 'web/components/text-button'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
|
||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
export function ReferralsButton(props: {
|
||||
user: User
|
||||
currentUser?: User
|
||||
className?: string
|
||||
}) {
|
||||
const { user, currentUser, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const referralIds = useReferrals(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
|
||||
Referrals
|
||||
</TextButton>
|
||||
|
|
|
@ -6,13 +6,15 @@ export const linkClass =
|
|||
'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||
|
||||
export const SiteLink = (props: {
|
||||
href: string
|
||||
href: string | undefined
|
||||
children?: ReactNode
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}) => {
|
||||
const { href, children, onClick, className } = props
|
||||
|
||||
if (!href) return <>{children}</>
|
||||
|
||||
return (
|
||||
<MaybeLink href={href}>
|
||||
<a
|
||||
|
|
|
@ -128,7 +128,7 @@ function DownTip(props: { onClick?: () => void }) {
|
|||
|
||||
function UpTip(props: { onClick?: () => void; value: number }) {
|
||||
const { onClick, value } = props
|
||||
const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon
|
||||
const IconKind = value > TIP_SIZE ? ChevronDoubleRightIcon : ChevronRightIcon
|
||||
return (
|
||||
<Tooltip
|
||||
className="h-6 w-6"
|
||||
|
|
302
web/components/usa-map/data.tsx
Normal file
302
web/components/usa-map/data.tsx
Normal 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',
|
||||
},
|
||||
}
|
85
web/components/usa-map/state-election-map.tsx
Normal file
85
web/components/usa-map/state-election-map.tsx
Normal 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
|
||||
}
|
106
web/components/usa-map/usa-map.tsx
Normal file
106
web/components/usa-map/usa-map.tsx
Normal 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>
|
||||
)
|
||||
}
|
34
web/components/usa-map/usa-state.tsx
Normal file
34
web/components/usa-map/usa-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { NextRouter, useRouter } from 'next/router'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import {
|
||||
ChatIcon,
|
||||
FolderIcon,
|
||||
PencilIcon,
|
||||
ScaleIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -24,39 +29,23 @@ import { FollowersButton, FollowingButton } from './following-button'
|
|||
import { UserFollowButton } from './follow-button'
|
||||
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||
import { ReferralsButton } from 'web/components/referrals-button'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import {
|
||||
BettingStreakModal,
|
||||
hasCompletedStreakToday,
|
||||
} from 'web/components/profile/betting-streak-modal'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
import { LoansModal } from './profile/loans-modal'
|
||||
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
import { capitalize } from 'lodash'
|
||||
|
||||
export function UserPage(props: { user: User }) {
|
||||
const { user } = props
|
||||
const router = useRouter()
|
||||
const currentUser = useUser()
|
||||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
setShowBettingStreakModal(showBettingStreak)
|
||||
setShowConfetti(claimedMana || showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
|
||||
setShowConfetti(claimedMana)
|
||||
const query = { ...router.query }
|
||||
if (query.claimedMana || query.show) {
|
||||
delete query['claimed-mana']
|
||||
|
@ -85,218 +74,159 @@ export function UserPage(props: { user: User }) {
|
|||
{showConfetti && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
)}
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${bannerUrl})`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative mb-20">
|
||||
<div className="absolute -top-10 left-4">
|
||||
<Col className="relative">
|
||||
<Row className="relative px-4 pt-4">
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={24}
|
||||
className="bg-white ring-4 ring-white"
|
||||
className="bg-white shadow-sm shadow-indigo-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top right buttons (e.g. edit, follow) */}
|
||||
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
<div className="absolute ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300">
|
||||
<SiteLink href="/profile">
|
||||
<PencilIcon className="h-5" />{' '}
|
||||
</SiteLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||
<Col>
|
||||
<span className="break-anywhere text-2xl font-bold">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
<Row className={'gap-3'}>
|
||||
<Col className={'items-center text-gray-500'}>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
<Col className="w-full gap-4 pl-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<Col>
|
||||
<span className="break-anywhere text-lg font-bold sm:text-2xl">
|
||||
{user.name}
|
||||
</span>
|
||||
<span>profit</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer items-center text-gray-500',
|
||||
isCurrentUser && !hasCompletedStreakToday(user)
|
||||
? 'grayscale'
|
||||
: 'grayscale-0'
|
||||
)}
|
||||
onClick={() => setShowBettingStreakModal(true)}
|
||||
>
|
||||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||
<span>streak</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={
|
||||
'flex-shrink-0 cursor-pointer items-center text-gray-500'
|
||||
}
|
||||
onClick={() => setShowLoansModal(true)}
|
||||
>
|
||||
<span className="text-green-600">
|
||||
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||
<span className="sm:text-md text-greyscale-4 text-sm">
|
||||
@{user.username}
|
||||
</span>
|
||||
<span>next loan</span>
|
||||
</Col>
|
||||
</Row>
|
||||
{isCurrentUser && (
|
||||
<ProfilePrivateStats
|
||||
currentUser={currentUser}
|
||||
profit={profit}
|
||||
user={user}
|
||||
router={router}
|
||||
/>
|
||||
)}
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
</div>
|
||||
<ProfilePublicStats
|
||||
className="sm:text-md text-greyscale-6 hidden text-sm md:inline"
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={4} />
|
||||
{user.bio && (
|
||||
<>
|
||||
<div>
|
||||
<Linkify text={user.bio}></Linkify>
|
||||
</div>
|
||||
<Spacer h={4} />
|
||||
</>
|
||||
)}
|
||||
{(user.website || user.twitterHandle || user.discordHandle) && (
|
||||
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-sm text-gray-500">{user.website}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
|
||||
<Row
|
||||
className={
|
||||
'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<SiteLink href="/referrals">
|
||||
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
||||
</SiteLink>{' '}
|
||||
You've gotten{' '}
|
||||
<ReferralsButton user={user} currentUser={currentUser} />
|
||||
</span>
|
||||
<ShareIconButton
|
||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||
toastClassName={'sm:-left-40 -left-40 min-w-[250%]'}
|
||||
buttonClassName={'h-10 w-10'}
|
||||
iconClassName={'h-8 w-8 text-indigo-700'}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<QueryUncontrolledTabs
|
||||
className="mb-4"
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 '}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Markets',
|
||||
content: (
|
||||
<CreatorContractsList user={currentUser} creator={user} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Comments',
|
||||
content: (
|
||||
<Col>
|
||||
<UserCommentsList user={user} />
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: capitalize(PAST_BETS),
|
||||
content: (
|
||||
<>
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Stats',
|
||||
content: (
|
||||
<Col className="mb-8">
|
||||
<Row className="mb-8 flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
<ReferralsButton user={user} />
|
||||
<GroupsButton user={user} />
|
||||
<UserLikesButton user={user} />
|
||||
<Col className="mx-4 mt-2">
|
||||
<Spacer h={1} />
|
||||
<ProfilePublicStats
|
||||
className="text-greyscale-6 text-sm md:hidden"
|
||||
user={user}
|
||||
/>
|
||||
<Spacer h={1} />
|
||||
{user.bio && (
|
||||
<>
|
||||
<div className="sm:text-md mt-2 text-sm sm:mt-0">
|
||||
<Linkify text={user.bio}></Linkify>
|
||||
</div>
|
||||
<Spacer h={2} />
|
||||
</>
|
||||
)}
|
||||
{(user.website || user.twitterHandle || user.discordHandle) && (
|
||||
<Row className="mb-2 flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.website}
|
||||
</span>
|
||||
</Row>
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
<QueryUncontrolledTabs
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 sm:pt-4 '}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Markets',
|
||||
tabIcon: <ScaleIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<CreatorContractsList user={currentUser} creator={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Portfolio',
|
||||
tabIcon: <FolderIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
<Spacer h={4} />
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Comments',
|
||||
tabIcon: <ChatIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<Col>
|
||||
<UserCommentsList user={user} />
|
||||
</Col>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
@ -314,3 +244,88 @@ export function defaultBannerUrl(userId: string) {
|
|||
]
|
||||
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
||||
}
|
||||
|
||||
export function ProfilePrivateStats(props: {
|
||||
currentUser: User | null | undefined
|
||||
profit: number
|
||||
user: User
|
||||
router: NextRouter
|
||||
}) {
|
||||
const { currentUser, profit, user, router } = props
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
setShowBettingStreakModal(showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<Row className={'justify-between gap-4 sm:justify-end'}>
|
||||
<Col className={'text-greyscale-4 text-md sm:text-lg'}>
|
||||
<span
|
||||
className={clsx(profit >= 0 ? 'text-green-600' : 'text-red-400')}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>
|
||||
<span className="mx-auto text-xs sm:text-sm">profit</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx('text-,d cursor-pointer sm:text-lg ')}
|
||||
onClick={() => setShowBettingStreakModal(true)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
!hasCompletedStreakToday(user)
|
||||
? 'opacity-50 grayscale'
|
||||
: 'grayscale-0'
|
||||
)}
|
||||
>
|
||||
🔥 {user.currentBettingStreak ?? 0}
|
||||
</span>
|
||||
<span className="text-greyscale-4 mx-auto text-xs sm:text-sm">
|
||||
streak
|
||||
</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={
|
||||
'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
|
||||
}
|
||||
onClick={() => setShowLoansModal(true)}
|
||||
>
|
||||
<span className="text-green-600">
|
||||
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||
</span>
|
||||
<span className="mx-auto text-xs sm:text-sm">next loan</span>
|
||||
</Col>
|
||||
</Row>
|
||||
{BettingStreakModal && (
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePublicStats(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
return (
|
||||
<Row className={'flex-wrap items-center gap-3'}>
|
||||
<FollowingButton user={user} className={className} />
|
||||
<FollowersButton user={user} className={className} />
|
||||
{/* <ReferralsButton user={user} className={className} /> */}
|
||||
<GroupsButton user={user} className={className} />
|
||||
{/* <UserLikesButton user={user} className={className} /> */}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,12 @@ import React from 'react'
|
|||
import { Row } from './layout/row'
|
||||
import { ConfirmationButton } from './confirmation-button'
|
||||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function WarningConfirmationButton(props: {
|
||||
amount: number | undefined
|
||||
outcome?: 'YES' | 'NO' | undefined
|
||||
marketType: 'freeResponse' | 'binary'
|
||||
warning?: string
|
||||
onSubmit: () => void
|
||||
disabled?: boolean
|
||||
|
@ -14,26 +18,36 @@ export function WarningConfirmationButton(props: {
|
|||
submitButtonClassName?: string
|
||||
}) {
|
||||
const {
|
||||
amount,
|
||||
onSubmit,
|
||||
warning,
|
||||
disabled,
|
||||
isSubmitting,
|
||||
openModalButtonClass,
|
||||
submitButtonClassName,
|
||||
outcome,
|
||||
marketType,
|
||||
} = props
|
||||
|
||||
if (!warning) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
openModalButtonClass,
|
||||
isSubmitting ? 'loading' : '',
|
||||
disabled && 'btn-disabled'
|
||||
isSubmitting ? 'loading btn-disabled' : '',
|
||||
disabled && 'btn-disabled',
|
||||
marketType === 'binary'
|
||||
? !outcome
|
||||
? 'btn-disabled bg-greyscale-2'
|
||||
: ''
|
||||
: ''
|
||||
)}
|
||||
onClick={onSubmit}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: amount
|
||||
? `Wager ${formatMoney(amount)}`
|
||||
: 'Wager'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -45,7 +59,7 @@ export function WarningConfirmationButton(props: {
|
|||
openModalButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Submit',
|
||||
label: amount ? `Wager ${formatMoney(amount)}` : 'Wager',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Cancel',
|
||||
|
|
|
@ -35,10 +35,11 @@ export function YesNoSelector(props: {
|
|||
<button
|
||||
className={clsx(
|
||||
commonClassNames,
|
||||
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
|
||||
selected == 'YES'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-primary bg-white',
|
||||
? 'border-teal-500 bg-teal-500 text-white'
|
||||
: selected == 'NO'
|
||||
? 'border-greyscale-3 text-greyscale-3 bg-white hover:border-teal-500 hover:text-teal-500'
|
||||
: 'border-teal-500 bg-white text-teal-500 hover:bg-teal-50',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => onSelect('YES')}
|
||||
|
@ -52,10 +53,11 @@ export function YesNoSelector(props: {
|
|||
<button
|
||||
className={clsx(
|
||||
commonClassNames,
|
||||
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
|
||||
selected == 'NO'
|
||||
? 'bg-red-400 text-white'
|
||||
: 'bg-white text-red-400',
|
||||
? 'border-red-400 bg-red-400 text-white'
|
||||
: selected == 'YES'
|
||||
? 'border-greyscale-3 text-greyscale-3 bg-white hover:border-red-400 hover:text-red-400'
|
||||
: 'border-red-400 bg-white text-red-400 hover:bg-red-50',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => onSelect('NO')}
|
||||
|
|
17
web/hooks/use-element-width.tsx
Normal file
17
web/hooks/use-element-width.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { RefObject, useState, useEffect } from 'react'
|
||||
|
||||
// todo: consider consolidation with use-measure-size
|
||||
export const useElementWidth = <T extends Element>(ref: RefObject<T>) => {
|
||||
const [width, setWidth] = useState<number>()
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWidth(ref.current?.clientWidth)
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [ref])
|
||||
return width
|
||||
}
|
|
@ -1,7 +1,13 @@
|
|||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// matches talwind sm breakpoint
|
||||
export function useIsMobile() {
|
||||
const { width } = useWindowSize()
|
||||
return (width ?? 0) < 640
|
||||
export function useIsMobile(threshold?: number) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>()
|
||||
useEffect(() => {
|
||||
// 640 matches tailwind sm breakpoint
|
||||
const onResize = () => setIsMobile(window.innerWidth < (threshold ?? 640))
|
||||
onResize()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [threshold])
|
||||
return isMobile
|
||||
}
|
||||
|
|
|
@ -131,7 +131,7 @@ function getCommentsOnPostCollection(postId: string) {
|
|||
}
|
||||
|
||||
export async function listAllComments(contractId: string) {
|
||||
return await getValues<Comment>(
|
||||
return await getValues<ContractComment>(
|
||||
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,8 +6,9 @@ import {
|
|||
updateDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { Post } from 'common/post'
|
||||
import { coll, getValue, listenForValue } from './utils'
|
||||
import { DateDoc, Post } from 'common/post'
|
||||
import { coll, getValue, getValues, listenForValue } from './utils'
|
||||
import { getUserByUsername } from './users'
|
||||
|
||||
export const posts = coll<Post>('posts')
|
||||
|
||||
|
@ -44,3 +45,22 @@ export async function listPosts(postIds?: string[]) {
|
|||
if (postIds === undefined) return []
|
||||
return Promise.all(postIds.map(getPost))
|
||||
}
|
||||
|
||||
export async function getDateDocs() {
|
||||
const q = query(posts, where('type', '==', 'date-doc'))
|
||||
return getValues<DateDoc>(q)
|
||||
}
|
||||
|
||||
export async function getDateDoc(username: string) {
|
||||
const user = await getUserByUsername(username)
|
||||
if (!user) return null
|
||||
|
||||
const q = query(
|
||||
posts,
|
||||
where('type', '==', 'date-doc'),
|
||||
where('creatorId', '==', user.id)
|
||||
)
|
||||
const docs = await getValues<DateDoc>(q)
|
||||
const post = docs.length === 0 ? null : docs[0]
|
||||
return { post, user }
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import {
|
|||
Identify,
|
||||
} from '@amplitude/analytics-browser'
|
||||
|
||||
import * as Sprig from 'web/lib/service/sprig'
|
||||
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
init(ENV_CONFIG.amplitudeApiKey ?? '', undefined, { includeReferrer: true })
|
||||
|
@ -33,10 +35,12 @@ export const withTracking =
|
|||
|
||||
export async function identifyUser(userId: string) {
|
||||
setUserId(userId)
|
||||
Sprig.setUserId(userId)
|
||||
}
|
||||
|
||||
export async function setUserProperty(property: string, value: string) {
|
||||
const identifyObj = new Identify()
|
||||
identifyObj.set(property, value)
|
||||
await identify(identifyObj)
|
||||
Sprig.setAttributes({ [property]: value })
|
||||
}
|
||||
|
|
33
web/lib/service/sprig.ts
Normal file
33
web/lib/service/sprig.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// Integrate Sprig
|
||||
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
try {
|
||||
;(function (l, e, a, p) {
|
||||
if (window.Sprig) return
|
||||
window.Sprig = function (...args) {
|
||||
S._queue.push(args)
|
||||
}
|
||||
const S = window.Sprig
|
||||
S.appId = a
|
||||
S._queue = []
|
||||
window.UserLeap = S
|
||||
a = l.createElement('script')
|
||||
a.async = 1
|
||||
a.src = e + '?id=' + S.appId
|
||||
p = l.getElementsByTagName('script')[0]
|
||||
p.parentNode.insertBefore(a, p)
|
||||
})(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId)
|
||||
} catch (error) {
|
||||
console.log('Error initializing Sprig, please complain to Barak', error)
|
||||
}
|
||||
|
||||
export function setUserId(userId: string): void {
|
||||
window.Sprig('setUserId', userId)
|
||||
}
|
||||
|
||||
export function setAttributes(attributes: Record<string, unknown>): void {
|
||||
window.Sprig('setAttributes', attributes)
|
||||
}
|
|
@ -39,6 +39,12 @@
|
|||
"browser-image-compression": "2.0.0",
|
||||
"clsx": "1.1.1",
|
||||
"cors": "2.8.5",
|
||||
"d3-array": "3.2.0",
|
||||
"d3-axis": "3.0.0",
|
||||
"d3-brush": "3.0.0",
|
||||
"d3-scale": "4.0.2",
|
||||
"d3-shape": "3.1.0",
|
||||
"d3-selection": "3.0.0",
|
||||
"daisyui": "1.16.4",
|
||||
"dayjs": "1.10.7",
|
||||
"firebase": "9.9.3",
|
||||
|
@ -56,9 +62,9 @@
|
|||
"react-expanding-textarea": "2.3.5",
|
||||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"react-query": "3.39.0",
|
||||
"react-twitter-embed": "4.0.4",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"string-similarity": "^4.0.4",
|
||||
"tippy.js": "6.3.7"
|
||||
},
|
||||
|
@ -66,6 +72,7 @@
|
|||
"@tailwindcss/forms": "0.4.0",
|
||||
"@tailwindcss/line-clamp": "^0.3.1",
|
||||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@types/d3": "7.4.0",
|
||||
"@types/lodash": "4.14.178",
|
||||
"@types/node": "16.11.11",
|
||||
"@types/react": "17.0.43",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||
|
@ -44,8 +45,9 @@ import { useAdmin } from 'web/hooks/use-admin'
|
|||
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
|
||||
import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
|
||||
import BetButton from 'web/components/bet-button'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { BetsSummary } from 'web/components/bet-summary'
|
||||
import { listAllComments } from 'web/lib/firebase/comments'
|
||||
import { ContractComment } from 'common/comment'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -55,10 +57,15 @@ export async function getStaticPropz(props: {
|
|||
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||
const contractId = contract?.id
|
||||
const bets = contractId ? await listAllBets(contractId) : []
|
||||
const comments = contractId ? await listAllComments(contractId) : []
|
||||
|
||||
return {
|
||||
// Limit the data sent to the client. Client will still load all bets directly.
|
||||
props: { contract, bets: bets.slice(0, 5000) },
|
||||
props: {
|
||||
contract,
|
||||
// Limit the data sent to the client. Client will still load all bets/comments directly.
|
||||
bets: bets.slice(0, 5000),
|
||||
comments: comments.slice(0, 1000),
|
||||
},
|
||||
revalidate: 5, // regenerate after five seconds
|
||||
}
|
||||
}
|
||||
|
@ -70,9 +77,14 @@ export async function getStaticPaths() {
|
|||
export default function ContractPage(props: {
|
||||
contract: Contract | null
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
contract: null,
|
||||
bets: [],
|
||||
comments: [],
|
||||
}
|
||||
|
||||
const inIframe = useIsIframe()
|
||||
if (inIframe) {
|
||||
|
@ -147,7 +159,7 @@ export function ContractPageContent(
|
|||
contract: Contract
|
||||
}
|
||||
) {
|
||||
const { backToHome } = props
|
||||
const { backToHome, comments } = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const user = useUser()
|
||||
usePrefetch(user?.id)
|
||||
|
@ -167,6 +179,10 @@ export function ContractPageContent(
|
|||
[bets]
|
||||
)
|
||||
|
||||
const userBets = user
|
||||
? bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||
: []
|
||||
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -248,7 +264,19 @@ export function ContractPageContent(
|
|||
</>
|
||||
)}
|
||||
|
||||
<ContractTabs contract={contract} bets={bets} />
|
||||
<BetsSummary
|
||||
className="mb-4 px-2"
|
||||
contract={contract}
|
||||
userBets={userBets}
|
||||
/>
|
||||
|
||||
<ContractTabs
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
userBets={userBets}
|
||||
comments={comments}
|
||||
/>
|
||||
|
||||
{!user ? (
|
||||
<Col className="mt-4 max-w-sm items-center xl:hidden">
|
||||
<BetSignUpPrompt />
|
||||
|
|
28
web/pages/api/v0/market/[id]/add-liquidity.ts
Normal file
28
web/pages/api/v0/market/[id]/add-liquidity.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import {
|
||||
CORS_ORIGIN_MANIFOLD,
|
||||
CORS_ORIGIN_LOCALHOST,
|
||||
} from 'common/envs/constants'
|
||||
import { applyCorsHeaders } from 'web/lib/api/cors'
|
||||
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
|
||||
|
||||
export const config = { api: { bodyParser: true } }
|
||||
|
||||
export default async function route(req: NextApiRequest, res: NextApiResponse) {
|
||||
await applyCorsHeaders(req, res, {
|
||||
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
methods: 'POST',
|
||||
})
|
||||
|
||||
const { id } = req.query
|
||||
const contractId = id as string
|
||||
|
||||
if (req.body) req.body.contractId = contractId
|
||||
try {
|
||||
const backendRes = await fetchBackend(req, 'addliquidity')
|
||||
await forwardResponse(res, backendRes)
|
||||
} catch (err) {
|
||||
console.error('Error talking to cloud function: ', err)
|
||||
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||
}
|
||||
}
|
20
web/pages/cowp.tsx
Normal file
20
web/pages/cowp.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Link from 'next/link'
|
||||
import { Page } from 'web/components/page'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Page className="">
|
||||
<SEO
|
||||
title="COWP"
|
||||
description="A picture of a cowpy cowp copwer cowp saying 'salutations'"
|
||||
url="/cowp"
|
||||
/>
|
||||
<Link href="https://www.youtube.com/watch?v=FavUpD_IjVY">
|
||||
<img src="https://i.imgur.com/Lt54IiU.png" />
|
||||
</Link>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
|
@ -8,20 +8,24 @@ import { useUser } from 'web/hooks/use-user'
|
|||
|
||||
export default function DailyMovers() {
|
||||
const user = useUser()
|
||||
const bettorId = user?.id ?? undefined
|
||||
|
||||
const changes = useProbChanges({ bettorId })?.filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
|
||||
useTracking('view daily movers')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4">
|
||||
<Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" />
|
||||
<ProbChangeTable changes={changes} full />
|
||||
{user && <ProbChangesWrapper userId={user.id} />}
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChangesWrapper(props: { userId: string }) {
|
||||
const { userId } = props
|
||||
|
||||
const changes = useProbChanges({ bettorId: userId })?.filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
|
||||
return <ProbChangeTable changes={changes} full />
|
||||
}
|
||||
|
|
154
web/pages/date-docs/[username].tsx
Normal file
154
web/pages/date-docs/[username].tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { getDateDoc } from 'web/lib/firebase/posts'
|
||||
import { ArrowLeftIcon, LinkIcon } from '@heroicons/react/outline'
|
||||
import { Page } from 'web/components/page'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { DateDoc } from 'common/post'
|
||||
import { Content } from 'web/components/editor'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import Custom404 from '../404'
|
||||
import { ShareIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { Button } from 'web/components/button'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import toast from 'react-hot-toast'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]'
|
||||
import { usePost } from 'web/hooks/use-post'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useCommentsOnPost } from 'web/hooks/use-comments'
|
||||
|
||||
export async function getStaticProps(props: { params: { username: string } }) {
|
||||
const { username } = props.params
|
||||
const { user: creator, post } = (await getDateDoc(username)) ?? {
|
||||
creator: null,
|
||||
post: null,
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
creator,
|
||||
post,
|
||||
},
|
||||
revalidate: 5, // regenerate after five seconds
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return { paths: [], fallback: 'blocking' }
|
||||
}
|
||||
|
||||
export default function DateDocPageHelper(props: {
|
||||
creator: User | null
|
||||
post: DateDoc | null
|
||||
}) {
|
||||
const { creator, post } = props
|
||||
|
||||
if (!creator || !post) return <Custom404 />
|
||||
|
||||
return <DateDocPage creator={creator} post={post} />
|
||||
}
|
||||
|
||||
function DateDocPage(props: { creator: User; post: DateDoc }) {
|
||||
const { creator, post } = props
|
||||
|
||||
const tips = useTipTxns({ postId: post.id })
|
||||
const comments = useCommentsOnPost(post.id) ?? []
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="mx-auto w-full max-w-xl gap-6 sm:mb-6">
|
||||
<SiteLink href="/date-docs">
|
||||
<Row className="items-center gap-2">
|
||||
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<div>Date docs</div>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
<DateDocPost dateDoc={post} creator={creator} />
|
||||
<Col className="gap-4 rounded-lg bg-white px-6 py-4">
|
||||
<div className="">Add your endorsement of {creator.name}!</div>
|
||||
<PostCommentsActivity post={post} comments={comments} tips={tips} />
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export function DateDocPost(props: {
|
||||
dateDoc: DateDoc
|
||||
creator: User
|
||||
link?: boolean
|
||||
}) {
|
||||
const { dateDoc, creator, link } = props
|
||||
const { content, birthday, contractSlug } = dateDoc
|
||||
const { name, username } = creator
|
||||
|
||||
const user = useUser()
|
||||
const post = usePost(dateDoc.id) ?? dateDoc
|
||||
|
||||
const age = dayjs().diff(birthday, 'year')
|
||||
const shareUrl = `https://${DOMAIN}/date-docs/${username}`
|
||||
const marketUrl = `https://${DOMAIN}/${username}/${contractSlug}`
|
||||
|
||||
return (
|
||||
<Col className="gap-6 rounded-lg bg-white px-6 py-6">
|
||||
<SiteLink href={link ? `/date-docs/${creator.username}` : undefined}>
|
||||
<Col className="gap-6">
|
||||
<Row className="relative justify-between gap-4 text-2xl">
|
||||
<div>
|
||||
{name}, {age}
|
||||
</div>
|
||||
|
||||
<Col className={clsx(link && 'absolute', 'right-0 px-2')}>
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
copyToClipboard(shareUrl)
|
||||
toast.success('Link copied!', {
|
||||
icon: (
|
||||
<LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
track('copy share post link')
|
||||
}}
|
||||
>
|
||||
<ShareIcon
|
||||
className={clsx('mr-2 h-[24px] w-5')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="!hover:no-underline !decoration-0"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Share
|
||||
</div>
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</SiteLink>
|
||||
{user && user.id === creator.id ? (
|
||||
<RichEditPost post={post} />
|
||||
) : (
|
||||
<Content content={content} />
|
||||
)}
|
||||
<div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3">
|
||||
<iframe
|
||||
height="405"
|
||||
src={marketUrl}
|
||||
title=""
|
||||
frameBorder="0"
|
||||
className="w-full rounded-xl bg-white p-10"
|
||||
></iframe>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
124
web/pages/date-docs/create.tsx
Normal file
124
web/pages/date-docs/create.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import Router from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { DateDoc } from 'common/post'
|
||||
import { useTextEditor, TextEditor } from 'web/components/editor'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { createPost } from 'web/lib/firebase/api'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Button } from 'web/components/button'
|
||||
import dayjs from 'dayjs'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { MAX_QUESTION_LENGTH } from 'common/contract'
|
||||
|
||||
export default function CreateDateDocPage() {
|
||||
const user = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) Router.push('/date')
|
||||
})
|
||||
|
||||
const title = `${user?.name}'s Date Doc`
|
||||
const [birthday, setBirthday] = useState<undefined | string>(undefined)
|
||||
const [question, setQuestion] = useState(
|
||||
'Will I find a partner in the next 3 months?'
|
||||
)
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
disabled: isSubmitting,
|
||||
})
|
||||
|
||||
const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined
|
||||
const isValid =
|
||||
user && birthday && editor && editor.isEmpty === false && question
|
||||
|
||||
async function saveDateDoc() {
|
||||
if (!user || !editor || !birthdayTime) return
|
||||
|
||||
const newPost: Omit<
|
||||
DateDoc,
|
||||
'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug'
|
||||
> & { question: string } = {
|
||||
title,
|
||||
content: editor.getJSON(),
|
||||
bounty: 0,
|
||||
birthday: birthdayTime,
|
||||
type: 'date-doc',
|
||||
question,
|
||||
}
|
||||
|
||||
const result = await createPost(newPost)
|
||||
|
||||
if (result.post) {
|
||||
await Router.push(`/date-docs/${user.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="rounded-lg px-6 py-4 pb-4 sm:py-0">
|
||||
<Row className="mb-8 items-center justify-between">
|
||||
<Title className="!my-0 text-blue-500" text="Your Date Doc" />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || upload.isLoading}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true)
|
||||
await saveDateDoc()
|
||||
setIsSubmitting(false)
|
||||
}}
|
||||
color="blue"
|
||||
>
|
||||
{isSubmitting ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
<Col className="gap-8">
|
||||
<Col className="max-w-[160px] justify-start gap-4">
|
||||
<div className="">Birthday</div>
|
||||
<input
|
||||
type={'date'}
|
||||
className="input input-bordered"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setBirthday(e.target.value)}
|
||||
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||
disabled={isSubmitting}
|
||||
value={birthday}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col className="gap-4">
|
||||
<div className="">
|
||||
Tell us about you! What are you looking for?
|
||||
</div>
|
||||
<TextEditor editor={editor} upload={upload} />
|
||||
</Col>
|
||||
|
||||
<Col className="gap-4">
|
||||
<div className="">
|
||||
Finally, we'll create an (unlisted) prediction market!
|
||||
</div>
|
||||
|
||||
<Col className="gap-2">
|
||||
<Textarea
|
||||
className="input input-bordered resize-none"
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value || '')}
|
||||
/>
|
||||
<div className="ml-2 text-gray-500">Cost: M$100</div>
|
||||
</Col>
|
||||
</Col>
|
||||
</Col>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
72
web/pages/date-docs/index.tsx
Normal file
72
web/pages/date-docs/index.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { Page } from 'web/components/page'
|
||||
import { PlusCircleIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { getDateDocs } from 'web/lib/firebase/posts'
|
||||
import type { DateDoc } from 'common/post'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Button } from 'web/components/button'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { getUser, User } from 'web/lib/firebase/users'
|
||||
import { DateDocPost } from './[username]'
|
||||
|
||||
export async function getStaticProps() {
|
||||
const dateDocs = await getDateDocs()
|
||||
const docCreators = await Promise.all(
|
||||
dateDocs.map((d) => getUser(d.creatorId))
|
||||
)
|
||||
|
||||
return {
|
||||
props: {
|
||||
dateDocs,
|
||||
docCreators,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
}
|
||||
}
|
||||
|
||||
export default function DatePage(props: {
|
||||
dateDocs: DateDoc[]
|
||||
docCreators: User[]
|
||||
}) {
|
||||
const { dateDocs, docCreators } = props
|
||||
const user = useUser()
|
||||
|
||||
const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<Row className="items-center justify-between p-4 sm:p-0">
|
||||
<Title className="!my-0 px-2 text-blue-500" text="Date docs" />
|
||||
{!hasDoc && (
|
||||
<SiteLink href="/date-docs/create" className="!no-underline">
|
||||
<Button className="flex flex-row gap-1" color="blue">
|
||||
<PlusCircleIcon
|
||||
className={'h-5 w-5 flex-shrink-0 text-white'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
New
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
<Spacer h={6} />
|
||||
<Col className="gap-4">
|
||||
{dateDocs.map((dateDoc, i) => (
|
||||
<DateDocPost
|
||||
key={dateDoc.id}
|
||||
dateDoc={dateDoc}
|
||||
creator={docCreators[i]}
|
||||
link
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -2,7 +2,6 @@ import { Bet } from 'common/bet'
|
|||
import { Contract } from 'common/contract'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { useState } from 'react'
|
||||
import { AnswersGraph } from 'web/components/answers/answers-graph'
|
||||
import { BetInline } from 'web/components/bet-inline'
|
||||
import { Button } from 'web/components/button'
|
||||
import {
|
||||
|
@ -12,8 +11,7 @@ import {
|
|||
PseudoNumericResolutionOrExpectation,
|
||||
} from 'web/components/contract/contract-card'
|
||||
import { MarketSubheader } from 'web/components/contract/contract-details'
|
||||
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
|
||||
import { NumericGraph } from 'web/components/contract/numeric-graph'
|
||||
import { ContractChart } from 'web/components/charts/contract'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
|
@ -134,22 +132,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
)}
|
||||
|
||||
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
|
||||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph
|
||||
contract={contract}
|
||||
bets={[...bets].reverse()}
|
||||
height={graphHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<AnswersGraph contract={contract} bets={bets} height={graphHeight} />
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericGraph contract={contract} height={graphHeight} />
|
||||
)}
|
||||
<ContractChart contract={contract} bets={bets} height={graphHeight} />
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -122,7 +122,7 @@ const groupSubpages = [
|
|||
export default function GroupPage(props: {
|
||||
group: Group | null
|
||||
memberIds: string[]
|
||||
creator: User
|
||||
creator: User | null
|
||||
topTraders: { user: User; score: number }[]
|
||||
topCreators: { user: User; score: number }[]
|
||||
messages: GroupComment[]
|
||||
|
@ -163,11 +163,11 @@ export default function GroupPage(props: {
|
|||
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||
|
||||
useSaveReferral(user, {
|
||||
defaultReferrerUsername: creator.username,
|
||||
defaultReferrerUsername: creator?.username,
|
||||
groupId: group?.id,
|
||||
})
|
||||
|
||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||
if (group === null || !groupSubpages.includes(page) || slugs[2] || !creator) {
|
||||
return <Custom404 />
|
||||
}
|
||||
const isCreator = user && group && user.id === group.creatorId
|
||||
|
|
|
@ -102,6 +102,32 @@ export default function Groups(props: {
|
|||
className="mb-4"
|
||||
currentPageForAnalytics={'groups'}
|
||||
tabs={[
|
||||
{
|
||||
title: 'All',
|
||||
content: (
|
||||
<Col>
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search groups"
|
||||
value={query}
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{matchesOrderedByMostContractAndMembers.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
creator={creatorsDict[group.creatorId]}
|
||||
user={user}
|
||||
isMember={memberGroupIds.includes(group.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
...(user
|
||||
? [
|
||||
{
|
||||
|
@ -136,32 +162,6 @@ export default function Groups(props: {
|
|||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'All',
|
||||
content: (
|
||||
<Col>
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search groups"
|
||||
value={query}
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{matchesOrderedByMostContractAndMembers.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
creator={creatorsDict[group.creatorId]}
|
||||
user={user}
|
||||
isMember={memberGroupIds.includes(group.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
@ -33,7 +33,6 @@ import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
|||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { ProfitBadge } from 'web/components/bets-list'
|
||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
|
@ -45,6 +44,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch'
|
|||
import { Title } from 'web/components/title'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts'
|
||||
import { ProfitBadge } from 'web/components/profit-badge'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
|
||||
export default function Home() {
|
||||
|
@ -55,6 +55,13 @@ export default function Home() {
|
|||
useSaveReferral()
|
||||
usePrefetch(user?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
// Go to landing page if not logged in.
|
||||
Router.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const groups = useMemberGroupsSubscription(user)
|
||||
|
||||
const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? [])
|
||||
|
@ -279,9 +286,9 @@ function GroupSection(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function DailyMoversSection(props: { userId: string | null | undefined }) {
|
||||
function DailyMoversSection(props: { userId: string }) {
|
||||
const { userId } = props
|
||||
const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter(
|
||||
const changes = useProbChanges({ bettorId: userId })?.filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
|
||||
|
|
92
web/pages/midterms.tsx
Normal file
92
web/pages/midterms.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import {
|
||||
StateElectionMarket,
|
||||
StateElectionMap,
|
||||
} from 'web/components/usa-map/state-election-map'
|
||||
|
||||
const senateMidterms: StateElectionMarket[] = [
|
||||
{
|
||||
state: 'AZ',
|
||||
creatorUsername: 'BTE',
|
||||
slug: 'will-blake-masters-win-the-arizona',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
{
|
||||
state: 'OH',
|
||||
creatorUsername: 'BTE',
|
||||
slug: 'will-jd-vance-win-the-ohio-senate-s',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
{
|
||||
state: 'WI',
|
||||
creatorUsername: 'BTE',
|
||||
slug: 'will-ron-johnson-be-reelected-in-th',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
{
|
||||
state: 'FL',
|
||||
creatorUsername: 'BTE',
|
||||
slug: 'will-marco-rubio-be-reelected-to-th',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
{
|
||||
state: 'PA',
|
||||
creatorUsername: 'MattP',
|
||||
slug: 'will-dr-oz-be-elected-to-the-us-sen',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
{
|
||||
state: 'GA',
|
||||
creatorUsername: 'NcyRocks',
|
||||
slug: 'will-a-democrat-win-the-2022-us-sen-3d2432ba6d79',
|
||||
isWinRepublican: false,
|
||||
},
|
||||
{
|
||||
state: 'NV',
|
||||
creatorUsername: 'NcyRocks',
|
||||
slug: 'will-a-democrat-win-the-2022-us-sen',
|
||||
isWinRepublican: false,
|
||||
},
|
||||
{
|
||||
state: 'NC',
|
||||
creatorUsername: 'NcyRocks',
|
||||
slug: 'will-a-democrat-win-the-2022-us-sen-6f1a901e1fcf',
|
||||
isWinRepublican: false,
|
||||
},
|
||||
{
|
||||
state: 'NH',
|
||||
creatorUsername: 'NcyRocks',
|
||||
slug: 'will-a-democrat-win-the-2022-us-sen-23194a72f1b7',
|
||||
isWinRepublican: false,
|
||||
},
|
||||
{
|
||||
state: 'UT',
|
||||
creatorUsername: 'SG',
|
||||
slug: 'will-mike-lee-win-the-2022-utah-sen',
|
||||
isWinRepublican: true,
|
||||
},
|
||||
]
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Page className="">
|
||||
<Col className="items-center justify-center">
|
||||
<Title text="2022 US Senate Midterms" className="mt-8" />
|
||||
<StateElectionMap markets={senateMidterms} />
|
||||
|
||||
<iframe
|
||||
src="https://manifold.markets/embed/NathanpmYoung/will-the-democrats-control-the-sena"
|
||||
title="Will the Democrats control the Senate after the Midterms?"
|
||||
frameBorder="0"
|
||||
width={800}
|
||||
height={400}
|
||||
className="mt-8"
|
||||
></iframe>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
|
@ -971,6 +971,8 @@ function ContractResolvedNotification(props: {
|
|||
const { sourceText, data } = notification
|
||||
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
|
||||
const subtitle = 'resolved the market'
|
||||
const profitable = userPayout >= userInvestment
|
||||
const ROI = (userPayout - userInvestment) / userInvestment
|
||||
|
||||
const resolutionDescription = () => {
|
||||
if (!sourceText) return <div />
|
||||
|
@ -1002,23 +1004,21 @@ function ContractResolvedNotification(props: {
|
|||
|
||||
const description =
|
||||
userInvestment && userPayout !== undefined ? (
|
||||
<Row className={'gap-1 '}>
|
||||
Resolved: {resolutionDescription()}
|
||||
Invested:
|
||||
<>
|
||||
Resolved: {resolutionDescription()} Invested:
|
||||
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
|
||||
Payout:
|
||||
<span
|
||||
className={clsx(
|
||||
userPayout > 0 ? 'text-primary' : 'text-red-500',
|
||||
'truncate'
|
||||
profitable ? 'text-primary' : 'text-red-500',
|
||||
'truncate text-ellipsis'
|
||||
)}
|
||||
>
|
||||
{formatMoney(userPayout)}
|
||||
{` (${userPayout > 0 ? '+' : ''}${Math.round(
|
||||
((userPayout - userInvestment) / userInvestment) * 100
|
||||
)}%)`}
|
||||
{userPayout > 0 &&
|
||||
` (${profitable ? '+' : ''}${Math.round(ROI * 100)}%)`}
|
||||
</span>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<span>Resolved {resolutionDescription()}</span>
|
||||
)
|
||||
|
@ -1038,9 +1038,7 @@ function ContractResolvedNotification(props: {
|
|||
highlighted={highlighted}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
<Row>
|
||||
<span>{description}</span>
|
||||
</Row>
|
||||
<Row className={'line-clamp-2 space-x-1'}>{description}</Row>
|
||||
</NotificationFrame>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -34,9 +34,9 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
|||
|
||||
return {
|
||||
props: {
|
||||
post: post,
|
||||
creator: creator,
|
||||
comments: comments,
|
||||
post,
|
||||
creator,
|
||||
comments,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -117,12 +117,7 @@ export default function PostPage(props: {
|
|||
|
||||
<Spacer h={4} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<PostCommentsActivity
|
||||
post={post}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={creator}
|
||||
/>
|
||||
<PostCommentsActivity post={post} comments={comments} tips={tips} />
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
@ -133,9 +128,8 @@ export function PostCommentsActivity(props: {
|
|||
post: Post
|
||||
comments: PostComment[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { post, comments, user, tips } = props
|
||||
const { post, comments, tips } = props
|
||||
const commentsByUserId = groupBy(comments, (c) => c.userId)
|
||||
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
|
||||
const topLevelComments = sortBy(
|
||||
|
@ -149,7 +143,6 @@ export function PostCommentsActivity(props: {
|
|||
{topLevelComments.map((parent) => (
|
||||
<PostCommentThread
|
||||
key={parent.id}
|
||||
user={user}
|
||||
post={post}
|
||||
parentComment={parent}
|
||||
threadComments={sortBy(
|
||||
|
@ -164,7 +157,7 @@ export function PostCommentsActivity(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function RichEditPost(props: { post: Post }) {
|
||||
export function RichEditPost(props: { post: Post }) {
|
||||
const { post } = props
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
|
|
@ -13,7 +13,6 @@ import { Page } from 'web/components/page'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { Title } from 'web/components/title'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||
import { changeUserInfo } from 'web/lib/firebase/api'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
|
@ -176,27 +175,6 @@ export default function ProfilePage(props: {
|
|||
onBlur={updateUsername}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Allow users with M$ 2000 of assets to set custom banners */}
|
||||
{/* <EditUserField
|
||||
user={user}
|
||||
field="bannerUrl"
|
||||
label="Banner Url"
|
||||
isEditing={isEditing}
|
||||
/> */}
|
||||
<label className="label">
|
||||
Banner image{' '}
|
||||
<span className="text-sm text-gray-400">Not editable for now</span>
|
||||
</label>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
user.bannerUrl || defaultBannerUrl(user.id)
|
||||
})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{(
|
||||
[
|
||||
['bio', 'Bio'],
|
||||
|
|
|
@ -107,7 +107,14 @@ const tourneys: Tourney[] = [
|
|||
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
||||
},
|
||||
|
||||
// Tournaments without awards get featured belows
|
||||
// Tournaments without awards get featured below
|
||||
{
|
||||
title: 'Criticism and Red Teaming Contest',
|
||||
blurb:
|
||||
'Which criticisms of Effective Altruism have been the most valuable?',
|
||||
endTime: toDate('Sep 30, 2022'),
|
||||
groupId: 'K86LmEmidMKdyCHdHNv4',
|
||||
},
|
||||
{
|
||||
title: 'SF 2022 Ballot',
|
||||
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
||||
|
|
|
@ -146,7 +146,8 @@ function TwitchPlaysManifoldMarkets(props: {
|
|||
<div>
|
||||
Instead of Twitch channel points we use our own play money, mana (M$).
|
||||
All viewers start with M$1,000 and can earn more for free by betting
|
||||
well.
|
||||
well. Just like channel points, mana cannot be converted to real
|
||||
money.
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
|
@ -176,35 +177,47 @@ function TwitchChatCommands() {
|
|||
<Col className="gap-4">
|
||||
<Subtitle text="For Chat" />
|
||||
<Command
|
||||
command="bet yes #"
|
||||
desc="Bets an amount of M$ on yes, for example !bet yes 20"
|
||||
command="y#"
|
||||
desc="Bets # amount of M$ on yes, for example !y20 would bet M$20 on yes."
|
||||
/>
|
||||
<Command
|
||||
command="n#"
|
||||
desc="Bets # amount of M$ on no, for example !n30 would bet M$30 on no."
|
||||
/>
|
||||
<Command command="bet no #" desc="Bets an amount of M$ on no." />
|
||||
<Command
|
||||
command="sell"
|
||||
desc="Sells all shares you own. Using this command causes you to
|
||||
cash out early before the market resolves. This could be profitable
|
||||
(if the probability has moved towards the direction you bet) or cause
|
||||
a loss, although at least you keep some mana. For maximum profit (but
|
||||
also risk) it is better to not sell and wait for a favourable
|
||||
resolution."
|
||||
cash out early based on the current probability.
|
||||
Shares will always be worth the most if you wait for a favourable resolution. But, selling allows you to lower risk, or trade throughout the event which can maximise earnings."
|
||||
/>
|
||||
<Command command="balance" desc="Shows how much M$ you have." />
|
||||
<Command command="allin yes" desc="Bets your entire balance on yes." />
|
||||
<Command command="allin no" desc="Bets your entire balance on no." />
|
||||
<Command
|
||||
command="position"
|
||||
desc="Shows how many shares you own in the current market and what your fixed payout is."
|
||||
/>
|
||||
<Command command="balance" desc="Shows how much M$ your account has." />
|
||||
|
||||
<div className="mb-4" />
|
||||
|
||||
<Subtitle text="For Mods/Streamer" />
|
||||
|
||||
<div>
|
||||
We recommend streamers sharing the link to the control dock with their
|
||||
mods. Alternatively, chat commands can be used to control markets.{' '}
|
||||
</div>
|
||||
|
||||
<Command
|
||||
command="create <question>"
|
||||
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
|
||||
command="create [question]"
|
||||
desc="Creates and features a question. Be careful, this will replace any question that is currently featured."
|
||||
/>
|
||||
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
|
||||
<Command command="resolve no" desc="Resolves the market as 'No'." />
|
||||
<Command
|
||||
command="resolve n/a"
|
||||
desc="Resolves the market as 'N/A' and refunds everyone their mana."
|
||||
command="resolve na"
|
||||
desc="Cancels the market and refunds everyone their mana."
|
||||
/>
|
||||
<Command
|
||||
command="unfeature"
|
||||
desc="Unfeatures the market. The market will still be open on our site and available to be refeatured again. If you plan to never interact with a market again we recommend resolving to N/A and not this command."
|
||||
/>
|
||||
</Col>
|
||||
</div>
|
||||
|
@ -384,8 +397,8 @@ function SetUpBot(props: {
|
|||
buttonOnClick={copyOverlayLink}
|
||||
>
|
||||
Create a new browser source in your streaming software such as OBS.
|
||||
Paste in the above link and resize it to your liking. We recommend
|
||||
setting the size to 400x400.
|
||||
Paste in the above link and type in the desired size. We recommend
|
||||
450x375.
|
||||
</BotSetupStep>
|
||||
<BotSetupStep
|
||||
stepNum={3}
|
||||
|
@ -397,6 +410,10 @@ function SetUpBot(props: {
|
|||
your OBS as a custom dock.
|
||||
</BotSetupStep>
|
||||
</div>
|
||||
<div>
|
||||
Need help? Contact SirSalty#5770 in Discord or email
|
||||
david@manifold.markets
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core'
|
|||
import clsx from 'clsx'
|
||||
import { PostComment } from 'common/comment'
|
||||
import { Post } from 'common/post'
|
||||
import { User } from 'common/user'
|
||||
import { Dictionary } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
@ -21,7 +20,6 @@ import { createCommentOnPost } from 'web/lib/firebase/comments'
|
|||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
export function PostCommentThread(props: {
|
||||
user: User | null | undefined
|
||||
post: Post
|
||||
threadComments: PostComment[]
|
||||
tips: CommentTipMap
|
||||
|
|
|
@ -16,6 +16,7 @@ module.exports = {
|
|||
),
|
||||
extend: {
|
||||
colors: {
|
||||
'red-25': '#FDF7F6',
|
||||
'greyscale-1': '#FBFBFF',
|
||||
'greyscale-2': '#E7E7F4',
|
||||
'greyscale-3': '#D8D8EB',
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user