Merge branch 'main' into better-bet-summary

This commit is contained in:
mantikoros 2022-09-27 12:51:41 -04:00
commit 636b9ea6ac
73 changed files with 4307 additions and 1080 deletions

View File

@ -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 computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime

View File

@ -10,6 +10,7 @@ export type Group = {
totalContracts: number totalContracts: number
totalMembers: number totalMembers: number
aboutPostId?: string aboutPostId?: string
postIds: string[]
chatDisabled?: boolean chatDisabled?: boolean
mostRecentContractAddedTime?: number mostRecentContractAddedTime?: number
cachedLeaderboard?: { cachedLeaderboard?: {

View File

@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
const profit = winnings - amount 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 } return { userId, profit, payout }
}) })

View File

@ -57,6 +57,7 @@ export type PrivateUser = {
email?: string email?: string
weeklyTrendingEmailSent?: boolean weeklyTrendingEmailSent?: boolean
weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string

View File

@ -1,3 +1,6 @@
export const MINUTE_MS = 60 * 1000 export const MINUTE_MS = 60 * 1000
export const HOUR_MS = 60 * MINUTE_MS export const HOUR_MS = 60 * MINUTE_MS
export const DAY_MS = 24 * HOUR_MS export const DAY_MS = 24 * HOUR_MS
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms))

View File

@ -40,7 +40,6 @@
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2", "node-fetch": "2",
"react-masonry-css": "1.0.16",
"stripe": "8.194.0", "stripe": "8.194.0",
"zod": "3.17.2" "zod": "3.17.2"
}, },
@ -48,7 +47,8 @@
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3" "firebase-functions-test": "0.3.3",
"puppeteer": "18.0.5"
}, },
"private": true "private": true
} }

View File

@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getValues } from './utils' import { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string().max(MAX_ANSWER_LENGTH), contractId: z.string().max(MAX_ANSWER_LENGTH),
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer return answer
}) })
await addUserToContractFollowers(contractId, auth.uid)
return answer return answer
}) })

View File

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

View File

@ -34,11 +34,12 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(),
}) })
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body) const { title, content, groupId } = validate(postSchema, req.body)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -60,6 +61,18 @@ export const createpost = newEndpoint({}, async (req, auth) => {
} }
await postRef.create(post) await postRef.create(post)
if (groupId) {
const groupRef = firestore.collection('groups').doc(groupId)
const group = await groupRef.get()
if (group.exists) {
const groupData = group.data()
if (groupData) {
const postIds = groupData.postIds ?? []
postIds.push(postRef.id)
await groupRef.update({ postIds })
}
}
}
return { status: 'success', post } return { status: 'success', post }
}) })

View File

@ -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>

View 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>

View File

@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils' import { contractUrl, getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { import {
getNotificationDestinationsForUser, PerContractInvestmentsData,
notification_preference, OverallPerformanceData,
} from '../../common/user-notification-preferences' } from 'functions/src/weekly-portfolio-emails'
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
reason: notification_reason_types, reason: notification_reason_types,
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'onboarding_flow' as notification_preference privateUser,
}` 'onboarding_flow'
)
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'onboarding_flow' as notification_preference privateUser,
}` 'onboarding_flow'
)
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold Markets one week anniversary gift', 'Manifold Markets one week anniversary gift',
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ privateUser,
'onboarding_flow' as notification_preference 'onboarding_flow'
}` )
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ privateUser,
'thank_you_for_purchases' as notification_preference 'thank_you_for_purchases'
}` )
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
) )
return return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'trending_markets' as notification_preference privateUser,
}` 'trending_markets'
)
const { name } = user const { name } = user
const firstName = name.split(' ')[0] 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) { function imageSourceUrl(contract: Contract) {
return buildCardUrl(getOpenGraphProps(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`] = investment.questionChange
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
)
}

View File

@ -27,9 +27,10 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
export * from './weekly-markets-emails' export * from './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag' export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-portfolio-emails'
// v2 // v2
export * from './health' export * from './health'

View File

@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
'contract', 'contract',
'closed', 'closed',
user, user,
'closed' + contract.id.slice(6, contract.id.length), contract.id + '-closed-at-' + contract.closeTime,
contract.closeTime?.toString() ?? new Date().toString(), contract.closeTime?.toString() ?? new Date().toString(),
{ contract } { contract }
) )

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils' import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions export const resetWeeklyEmailsFlags = functions
.runWith({ .runWith({
timeoutSeconds: 300, timeoutSeconds: 300,
memory: '4GB', memory: '4GB',
@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
privateUsers.map(async (user) => { privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({ return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false, weeklyTrendingEmailSent: false,
weeklyPortfolioUpdateEmailSent: false,
}) })
}) })
) )

View File

@ -5,6 +5,7 @@ import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { log } from './utils' import { log } from './utils'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
export const scoreContracts = functions export const scoreContracts = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 }) .runWith({ memory: '4GB', timeoutSeconds: 540 })
@ -16,11 +17,12 @@ const firestore = admin.firestore()
async function scoreContractsInternal() { async function scoreContractsInternal() {
const now = Date.now() const now = Date.now()
const lastHour = now - 60 * 60 * 1000 const hourAgo = now - HOUR_MS
const last3Days = now - 1000 * 60 * 60 * 24 * 3 const dayAgo = now - DAY_MS
const threeDaysAgo = now - DAY_MS * 3
const activeContractsSnap = await firestore const activeContractsSnap = await firestore
.collection('contracts') .collection('contracts')
.where('lastUpdatedTime', '>', lastHour) .where('lastUpdatedTime', '>', hourAgo)
.get() .get()
const activeContracts = activeContractsSnap.docs.map( const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract (doc) => doc.data() as Contract
@ -41,15 +43,21 @@ async function scoreContractsInternal() {
for (const contract of contracts) { for (const contract of contracts) {
const bets = await firestore const bets = await firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days) .where('createdTime', '>', threeDaysAgo)
.get() .get()
const bettors = bets.docs const bettors = bets.docs
.map((doc) => doc.data() as Bet) .map((doc) => doc.data() as Bet)
.map((bet) => bet.userId) .map((bet) => bet.userId)
const popularityScore = uniq(bettors).length const popularityScore = uniq(bettors).length
const wasCreatedToday = contract.createdTime > dayAgo
let dailyScore: number | undefined 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) const percentChange = Math.abs(contract.probChanges.day)
dailyScore = popularityScore * percentChange dailyScore = popularityScore * percentChange
} }

View 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',
}
}

File diff suppressed because it is too large Load Diff

View 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 '&amp;' with '&'
const clean = (str: string | undefined) => str?.replace(/&amp;/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'
)

View File

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

View File

@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View 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 }
}
)

View File

@ -170,3 +170,7 @@ export const chargeUser = (
export const getContractPath = (contract: Contract) => { export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}` return `/${contract.creatorUsername}/${contract.slug}`
} }
export function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() {
? await getAllPrivateUsers() ? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => { const privateUsersToSendEmailsTo = privateUsers
return ( .filter((user) => {
user.notificationPreferences.trending_markets.includes('email') && return (
!user.weeklyTrendingEmailSent user.notificationPreferences.trending_markets.includes('email') &&
) !user.weeklyTrendingEmailSent
}) )
})
.slice(150) // Send the emails out in batches
log( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
trendingContracts.map((c) => c.question).join('\n ') trendingContracts.map((c) => c.question).join('\n ')
) )
// TODO: convert to Promise.all
for (const privateUser of privateUsersToSendEmailsTo) { for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) { if (!privateUser.email) {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() {
}) })
if (contractsAvailableToSend.length < numContractsToSend) { if (contractsAvailableToSend.length < numContractsToSend) {
log('not enough new, unbet-on contracts to send to user', privateUser.id) 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 continue
} }
// choose random subset of contracts to send to user // choose random subset of contracts to send to user

View File

@ -0,0 +1,280 @@
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'
// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week
export const weeklyPortfolioUpdateEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
.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 marketProbabilityAWeekAgo =
cpmmContract.prob - cpmmContract.probChanges.week
const currentMarketProbability = cpmmContract.resolutionProbability
? cpmmContract.resolutionProbability
: cpmmContract.prob
const betsValueAWeekAgo = computeInvestmentValueCustomProb(
bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS),
contract,
marketProbabilityAWeekAgo
)
const currentBetsValue = computeInvestmentValueCustomProb(
bets,
contract,
currentMarketProbability
)
const marketChange =
currentMarketProbability - marketProbabilityAWeekAgo
return {
currentValue: currentBetsValue,
pastValue: betsValueAWeekAgo,
difference: currentBetsValue - betsValueAWeekAgo,
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: ${
currentMarketProbability > marketProbabilityAWeekAgo
? '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
}

View File

@ -6,6 +6,7 @@ import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { Row } from './layout/row'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -34,46 +35,53 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount) const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768 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 && ( return (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <>
{error === 'Insufficient balance' ? ( <Col className={className}>
<> <label className="font-sm md:font-lg">
Not enough funds. <span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}>
<span className="ml-1 text-indigo-500"> {label}
<SiteLink href="/add-funds">Buy more?</SiteLink> </span>
</span> <input
</> className={clsx(
) : ( 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error error && 'input-error',
)} isMobile ? 'w-24' : '',
</div> inputClassName
)} )}
</Col> 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 ( return (
<> <>
<AmountInput <Row className="gap-4">
amount={amount} <AmountInput
onChange={onAmountChange} amount={amount}
label={ENV_CONFIG.moneyMoniker} onChange={onAmountChange}
error={error} label={ENV_CONFIG.moneyMoniker}
disabled={disabled} error={error}
className={className} disabled={disabled}
inputClassName={inputClassName} className={className}
inputRef={inputRef} inputClassName={inputClassName}
/> inputRef={inputRef}
{showSlider && (
<input
type="range"
min="0"
max="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"
/> />
)} {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 my-auto align-middle xl:hidden"
step="5"
/>
)}
</Row>
</> </>
) )
} }

View File

@ -182,17 +182,17 @@ export function AnswerBetPanel(props: {
</Col> </Col>
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<WarningConfirmationButton <WarningConfirmationButton
marketType="freeResponse"
amount={betAmount}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled}
openModalButtonClass={clsx( openModalButtonClass={clsx(
'btn self-stretch', 'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary', betDisabled ? 'btn-disabled' : 'btn-primary'
isSubmitting ? 'loading' : ''
)} )}
/> />
) : ( ) : (

View File

@ -38,13 +38,26 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter( const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
) )
const [winningAnswers, notWinningAnswers] = partition( const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
answers,
(a) => a.id === resolution || (resolutions && resolutions[a.id]) 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( const sortedAnswers = [
sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)), ...sortBy(winningAnswers, (answer) =>
(a) => showAllAnswers || totalBets[a.id] > 0 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() const user = useUser()
@ -94,13 +107,13 @@ export function AnswersPanel(props: {
return ( return (
<Col className="gap-3"> <Col className="gap-3">
{(resolveOption || resolution) && {(resolveOption || resolution) &&
sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => ( sortedAnswers.map((answer) => (
<AnswerItem <AnswerItem
key={a.id} key={answer.id}
answer={a} answer={answer}
contract={contract} contract={contract}
showChoice={showChoice} showChoice={showChoice}
chosenProb={chosenAnswers[a.id]} chosenProb={chosenAnswers[answer.id]}
totalChosenProb={chosenTotal} totalChosenProb={chosenTotal}
onChoose={onChoose} onChoose={onChoose}
onDeselect={onDeselect} onDeselect={onDeselect}
@ -114,10 +127,10 @@ export function AnswersPanel(props: {
tradingAllowed(contract) ? '' : '-mb-6' tradingAllowed(contract) ? '' : '-mb-6'
)} )}
> >
{visibleAnswers.map((a) => ( {answerItems.map((item) => (
<OpenAnswer key={a.id} answer={a} contract={contract} /> <OpenAnswer key={item.id} answer={item} contract={contract} />
))} ))}
{invisibleAnswers.length > 0 && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
<Button <Button
className="self-end" className="self-end"
color="gray-white" color="gray-white"
@ -130,7 +143,7 @@ export function AnswersPanel(props: {
</Col> </Col>
)} )}
{answers.length === 0 && ( {answers.length <= 1 && (
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}

View File

@ -1,8 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { SimpleBetPanel } from './bet-panel' import { BuyPanel, SimpleBetPanel } from './bet-panel'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import {
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets' 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 { Col } from './layout/col'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' 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 */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -64,7 +71,6 @@ export default function BetButton(props: {
<SimpleBetPanel <SimpleBetPanel
className={betPanelClassName} className={betPanelClassName}
contract={contract} contract={contract}
selected="YES"
onBetSuccess={() => setOpen(false)} onBetSuccess={() => setOpen(false)}
hasShares={hasYesShares || hasNoShares} 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>
</>
)
}

View File

@ -43,6 +43,11 @@ import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { isAndroid, isIOS } from 'web/lib/util/device' import { isAndroid, isIOS } from 'web/lib/util/device'
import { WarningConfirmationButton } from './warning-confirmation-button' import { WarningConfirmationButton } from './warning-confirmation-button'
import { MarketIntroPanel } from './market-intro-panel' 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: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -105,11 +110,10 @@ export function BetPanel(props: {
export function SimpleBetPanel(props: { export function SimpleBetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
selected?: 'YES' | 'NO'
hasShares?: boolean hasShares?: boolean
onBetSuccess?: () => void onBetSuccess?: () => void
}) { }) {
const { contract, className, selected, hasShares, onBetSuccess } = props const { contract, className, hasShares, onBetSuccess } = props
const user = useUser() const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -139,7 +143,6 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<LimitOrderPanel <LimitOrderPanel
@ -162,38 +165,52 @@ export function SimpleBetPanel(props: {
) )
} }
function BuyPanel(props: { export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: Bet[]
hidden: boolean hidden: boolean
selected?: 'YES' | 'NO'
onBuySuccess?: () => void 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 initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected) const windowSize = useWindowSize()
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const initialOutcome =
windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(
initialOutcome
)
const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const [inputRef, focusAmountInput] = useFocus() const [inputRef, focusAmountInput] = useFocus()
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice) setOutcome(choice)
setWasSubmitted(false)
if (!isIOS() && !isAndroid()) { if (!isIOS() && !isAndroid()) {
focusAmountInput() focusAmountInput()
} }
} }
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
if (outcome === choice) {
setOutcome(undefined)
} else {
setOutcome(choice)
}
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
}
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
if (!outcome) { if (!outcome) {
setOutcome('YES') setOutcome('YES')
@ -214,9 +231,13 @@ function BuyPanel(props: {
.then((r) => { .then((r) => {
console.log('placed bet. Result:', r) console.log('placed bet. Result:', r)
setIsSubmitting(false) setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined) setBetAmount(undefined)
if (onBuySuccess) onBuySuccess() if (onBuySuccess) onBuySuccess()
else {
toast('Trade submitted!', {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
}
}) })
.catch((e) => { .catch((e) => {
if (e instanceof APIError) { if (e instanceof APIError) {
@ -249,6 +270,7 @@ function BuyPanel(props: {
unfilledBets as LimitBet[] unfilledBets as LimitBet[]
) )
const [seeLimit, setSeeLimit] = useState(false)
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame = const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb) formatPercent(resultProb) === formatPercent(initialProb)
@ -281,92 +303,133 @@ function BuyPanel(props: {
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
{isPseudoNumeric ? 'Direction' : 'Outcome'}
</div>
<YesNoSelector <YesNoSelector
className="mb-4" className="mb-4"
btnClassName="flex-1" btnClassName="flex-1"
selected={outcome} selected={outcome}
onSelect={(choice) => onBetChoice(choice)} onSelect={(choice) => {
if (mobileView) {
mobileOnBetChoice(choice)
} else {
onBetChoice(choice)
}
}}
isPseudoNumeric={isPseudoNumeric} isPseudoNumeric={isPseudoNumeric}
/> />
<Row className="my-3 justify-between text-left text-sm text-gray-500"> <Col
Amount className={clsx(
<span className={'xl:hidden'}> mobileView
Balance: {formatMoney(user?.balance ?? 0)} ? outcome === 'NO'
</span> ? 'bg-red-25'
</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'
: outcome === 'YES' : outcome === 'YES'
? 'btn-primary' ? 'bg-teal-50'
: 'border-none bg-red-400 hover:bg-red-500' : '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
? '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-8"
>
<Title text="Limit Order" />
<LimitOrderPanel
hidden={!seeLimit}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
</Modal>
</Col>
</Col> </Col>
) )
} }
@ -389,7 +452,6 @@ function LimitOrderPanel(props: {
const betChoice = 'YES' const betChoice = 'YES'
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const rangeError = const rangeError =
lowLimitProb !== undefined && lowLimitProb !== undefined &&
@ -437,7 +499,6 @@ function LimitOrderPanel(props: {
const noAmount = shares * (1 - (noLimitProb ?? 0)) const noAmount = shares * (1 - (noLimitProb ?? 0))
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
} }
@ -482,7 +543,6 @@ function LimitOrderPanel(props: {
.then((r) => { .then((r) => {
console.log('placed bet. Result:', r) console.log('placed bet. Result:', r)
setIsSubmitting(false) setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined) setBetAmount(undefined)
setLowLimitProb(undefined) setLowLimitProb(undefined)
setHighLimitProb(undefined) setHighLimitProb(undefined)
@ -718,8 +778,6 @@ function LimitOrderPanel(props: {
: `Submit order${hasTwoBets ? 's' : ''}`} : `Submit order${hasTwoBets ? 's' : ''}`}
</button> </button>
)} )}
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
</Col> </Col>
) )
} }
@ -866,11 +924,7 @@ export function SellPanel(props: {
<> <>
<AmountInput <AmountInput
amount={ amount={
amount amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
? Math.round(amount) === 0
? 0
: Math.floor(amount)
: undefined
} }
onChange={onAmountChange} onChange={onAmountChange}
label="Qty" label="Qty"

View File

@ -77,7 +77,7 @@ export function BetsList(props: { user: User }) {
}, [contractList]) }, [contractList])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open') const [filter, setFilter] = useState<BetFilter>('all')
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const start = page * CONTRACTS_PER_PAGE const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE
@ -155,34 +155,25 @@ export function BetsList(props: { user: User }) {
(c) => contractsMetrics[c.id].netPayout (c) => contractsMetrics[c.id].netPayout
) )
const totalPnl = user.profitCached.allTime
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent = const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return ( return (
<Col> <Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> <Row className="justify-between gap-4 sm:flex-row">
<Row className="gap-8"> <Col>
<Col> <div className="text-greyscale-6 text-xs sm:text-sm">
<div className="text-sm text-gray-500">Investment value</div> Investment value
<div className="text-lg"> </div>
{formatMoney(currentNetInvestment)}{' '} <div className="text-lg">
<ProfitBadge profitPercent={investedProfitPercent} /> {formatMoney(currentNetInvestment)}{' '}
</div> <ProfitBadge profitPercent={investedProfitPercent} />
</Col> </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="gap-8"> <Row className="gap-2">
<select <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} value={filter}
onChange={(e) => setFilter(e.target.value as BetFilter)} onChange={(e) => setFilter(e.target.value as BetFilter)}
> >
@ -195,7 +186,7 @@ export function BetsList(props: { user: User }) {
</select> </select>
<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} value={sort}
onChange={(e) => setSort(e.target.value as BetSort)} onChange={(e) => setSort(e.target.value as BetSort)}
> >
@ -205,7 +196,7 @@ export function BetsList(props: { user: User }) {
<option value="closeTime">Close date</option> <option value="closeTime">Close date</option>
</select> </select>
</Row> </Row>
</Col> </Row>
<Col className="mt-6 divide-y"> <Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? ( {displayedContracts.length === 0 ? (
@ -490,18 +481,24 @@ function BetRow(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_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 ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
) : saleAmount !== undefined ? ( ) : saleBet ? (
<>{formatMoney(saleAmount)} (sold)</> <>{formatMoney(payout)} (sold)</>
) : ( ) : (
formatMoney( formatMoney(payout)
isResolved
? resolvedPayout(contract, bet)
: calculateSaleAmount(contract, bet, unfilledBets)
)
) )
const payoutIfChosenDisplay = const payoutIfChosenDisplay =

View File

@ -48,6 +48,7 @@ import { Title } from './title'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' }, { label: 'Closing soon', value: 'close-date' },
@ -88,6 +89,7 @@ export function ContractSearch(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
hideQuickBet?: boolean hideQuickBet?: boolean
noLinkAvatar?: boolean noLinkAvatar?: boolean
showProbChange?: boolean
} }
headerClassName?: string headerClassName?: string
persistPrefix?: string persistPrefix?: string
@ -101,6 +103,7 @@ export function ContractSearch(props: {
loadMore: () => void loadMore: () => void
) => ReactNode ) => ReactNode
autoFocus?: boolean autoFocus?: boolean
profile?: boolean | undefined
}) { }) {
const { const {
user, user,
@ -121,6 +124,7 @@ export function ContractSearch(props: {
maxResults, maxResults,
renderContracts, renderContracts,
autoFocus, autoFocus,
profile,
} = props } = props
const [state, setState] = usePersistentState( const [state, setState] = usePersistentState(
@ -128,6 +132,7 @@ export function ContractSearch(props: {
numPages: 1, numPages: 1,
pages: [] as Contract[][], pages: [] as Contract[][],
showTime: null as ShowTime | null, showTime: null as ShowTime | null,
showProbChange: false,
}, },
!persistPrefix !persistPrefix
? undefined ? undefined
@ -181,8 +186,9 @@ export function ContractSearch(props: {
const newPage = results.hits as any as Contract[] const newPage = results.hits as any as Contract[]
const showTime = const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : null sort === 'close-date' || sort === 'resolve-date' ? sort : null
const showProbChange = sort === 'daily-score'
const pages = freshQuery ? [newPage] : [...state.pages, newPage] const pages = freshQuery ? [newPage] : [...state.pages, newPage]
setState({ numPages: results.nbPages, pages, showTime }) setState({ numPages: results.nbPages, pages, showTime, showProbChange })
if (freshQuery && isWholePage) window.scrollTo(0, 0) if (freshQuery && isWholePage) window.scrollTo(0, 0)
} }
} }
@ -200,6 +206,12 @@ export function ContractSearch(props: {
}, 100) }, 100)
).current ).current
const updatedCardUIOptions = useMemo(() => {
if (cardUIOptions?.showProbChange === undefined && state.showProbChange)
return { ...cardUIOptions, showProbChange: true }
return cardUIOptions
}, [cardUIOptions, state.showProbChange])
const contracts = state.pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
@ -229,6 +241,10 @@ export function ContractSearch(props: {
/> />
{renderContracts ? ( {renderContracts ? (
renderContracts(renderedContracts, performQuery) 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 <ContractsGrid
contracts={renderedContracts} contracts={renderedContracts}
@ -236,7 +252,7 @@ export function ContractSearch(props: {
showTime={state.showTime ?? undefined} showTime={state.showTime ?? undefined}
onContractClick={onContractClick} onContractClick={onContractClick}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardUIOptions={cardUIOptions} cardUIOptions={updatedCardUIOptions}
/> />
)} )}
</Col> </Col>

View File

@ -13,17 +13,16 @@ import {
PseudoNumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation,
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetButton from '../bet-button' import BetButton, { BinaryMobileBetting } from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { import {
Contract, Contract,
BinaryContract,
CPMMContract, CPMMContract,
CPMMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
BinaryContract,
} from 'common/contract' } from 'common/contract'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph' import { NumericGraph } from './numeric-graph'
@ -78,19 +77,18 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} /> <OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance <BinaryResolutionOrChance
className="hidden items-end xl:flex" className="flex items-end"
contract={contract} contract={contract}
large large
/> />
</Row> </Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} />
)}
</Row>
</Col> </Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} /> <ContractProbGraph contract={contract} bets={[...bets].reverse()} />
<Row className="items-center justify-between gap-4 xl:hidden">
{tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} />
)}
</Row>
</Col> </Col>
) )
} }

View File

@ -128,6 +128,7 @@ export function CreatorContractsList(props: {
creatorId: creator.id, creatorId: creator.id,
}} }}
persistPrefix={`user-${creator.id}`} persistPrefix={`user-${creator.id}`}
profile={true}
/> />
) )
} }

View File

@ -344,7 +344,7 @@ export function getColor(contract: Contract) {
return ( return (
OUTCOME_TO_COLOR[resolution as resolution] ?? OUTCOME_TO_COLOR[resolution as resolution] ??
// If resolved to a FR answer, use 'primary' // 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 // TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
return 'primary' return 'teal-500'
} }

View File

@ -0,0 +1,94 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group'
export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { group } = props
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
groupId: group?.id,
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
)
}

View File

@ -13,16 +13,18 @@ import { useDiscoverUsers } from 'web/hooks/use-users'
import { TextButton } from './text-button' import { TextButton } from './text-button'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
export function FollowingButton(props: { user: User }) { export function FollowingButton(props: { user: User; className?: string }) {
const { user } = props const { user, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const followingIds = useFollows(user.id) const followingIds = useFollows(user.id)
const followerIds = useFollowers(user.id) const followerIds = useFollowers(user.id)
return ( return (
<> <>
<TextButton onClick={() => setIsOpen(true)}> <TextButton onClick={() => setIsOpen(true)} className={className}>
<span className="font-semibold">{followingIds?.length ?? ''}</span>{' '} <span className={clsx('font-semibold')}>
{followingIds?.length ?? ''}
</span>{' '}
Following Following
</TextButton> </TextButton>
@ -69,15 +71,15 @@ export function EditFollowingButton(props: { user: User; className?: string }) {
) )
} }
export function FollowersButton(props: { user: User }) { export function FollowersButton(props: { user: User; className?: string }) {
const { user } = props const { user, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const followingIds = useFollows(user.id) const followingIds = useFollows(user.id)
const followerIds = useFollowers(user.id) const followerIds = useFollowers(user.id)
return ( return (
<> <>
<TextButton onClick={() => setIsOpen(true)}> <TextButton onClick={() => setIsOpen(true)} className={className}>
<span className="font-semibold">{followerIds?.length ?? ''}</span>{' '} <span className="font-semibold">{followerIds?.length ?? ''}</span>{' '}
Followers Followers
</TextButton> </TextButton>

View File

@ -16,29 +16,26 @@ import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: { export function GroupAboutPost(props: {
group: Group group: Group
isEditable: boolean isEditable: boolean
post: Post post: Post | null
}) { }) {
const { group, isEditable } = props const { group, isEditable } = props
const post = usePost(group.aboutPostId) ?? props.post const post = usePost(group.aboutPostId) ?? props.post
return ( return (
<div className="rounded-md bg-white p-4 "> <div className="rounded-md bg-white p-4 ">
{isEditable ? ( {isEditable && <RichEditGroupAboutPost group={group} post={post} />}
<RichEditGroupAboutPost group={group} post={post} /> {!isEditable && post && <Content content={post.content} />}
) : (
<Content content={post.content} />
)}
</div> </div>
) )
} }
function RichEditGroupAboutPost(props: { group: Group; post: Post }) { function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
const { group, post } = props const { group, post } = props
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
defaultValue: post.content, defaultValue: post?.content,
disabled: isSubmitting, disabled: isSubmitting,
}) })
@ -49,7 +46,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
content: editor.getJSON(), content: editor.getJSON(),
} }
if (group.aboutPostId == null) { if (post == null) {
const result = await createPost(newPost).catch((e) => { const result = await createPost(newPost).catch((e) => {
console.error(e) console.error(e)
return e return e
@ -65,6 +62,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
} }
async function deleteGroupAboutPost() { async function deleteGroupAboutPost() {
if (post == null) return
await deletePost(post) await deletePost(post)
await deleteFieldFromGroup(group, 'aboutPostId') await deleteFieldFromGroup(group, 'aboutPostId')
} }
@ -91,7 +89,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
</> </>
) : ( ) : (
<> <>
{group.aboutPostId == null ? ( {post == null ? (
<div className="text-center text-gray-500"> <div className="text-center text-gray-500">
<p className="text-sm"> <p className="text-sm">
No post has been added yet. No post has been added yet.

View File

@ -14,14 +14,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
import { GroupLinkItem } from 'web/pages/groups' import { GroupLinkItem } from 'web/pages/groups'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
export function GroupsButton(props: { user: User }) { export function GroupsButton(props: { user: User; className?: string }) {
const { user } = props const { user, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const groups = useMemberGroups(user.id) const groups = useMemberGroups(user.id)
return ( return (
<> <>
<TextButton onClick={() => setIsOpen(true)}> <TextButton onClick={() => setIsOpen(true)} className={className}>
<span className="font-semibold">{groups?.length ?? ''}</span> Groups <span className="font-semibold">{groups?.length ?? ''}</span> Groups
</TextButton> </TextButton>

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { useRouter, NextRouter } from 'next/router' import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { Col } from './col'
type Tab = { type Tab = {
title: string title: string
@ -55,11 +56,13 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)} )}
aria-current={activeIndex === i ? 'page' : undefined} aria-current={activeIndex === i ? 'page' : undefined}
> >
{tab.tabIcon && <span>{tab.tabIcon}</span>}
{tab.badge ? ( {tab.badge ? (
<span className="px-0.5 font-bold">{tab.badge}</span> <span className="px-0.5 font-bold">{tab.badge}</span>
) : null} ) : null}
{tab.title} <Col>
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>}
{tab.title}
</Col>
</a> </a>
))} ))}
</nav> </nav>

View File

@ -106,7 +106,7 @@ export const OUTCOME_TO_COLOR = {
} }
export function YesLabel() { export function YesLabel() {
return <span className="text-primary">YES</span> return <span className="text-teal-500">YES</span>
} }
export function HigherLabel() { export function HigherLabel() {

View File

@ -1,72 +1,155 @@
import { ResponsiveLine } from '@nivo/line' import { ResponsiveLine } from '@nivo/line'
import { PortfolioMetrics } from 'common/user' import { PortfolioMetrics } from 'common/user'
import { filterDefined } from 'common/util/array'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import dayjs from 'dayjs'
import { last } from 'lodash' import { last } from 'lodash'
import { memo } from 'react' import { memo } from 'react'
import { useWindowSize } from 'web/hooks/use-window-size' 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: { export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[] portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit' mode: 'value' | 'profit'
handleGraphDisplayChange: (arg0: string | number | null) => void
height?: number height?: number
includeTime?: boolean
}) { }) {
const { portfolioHistory, height, includeTime, mode } = props const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
const { width } = useWindowSize() const { width } = useWindowSize()
const points = portfolioHistory.map((p) => { const valuePoints = getPoints('value', portfolioHistory)
const { timestamp, balance, investmentValue, totalDeposits } = p const posProfitPoints = getPoints('posProfit', portfolioHistory)
const value = balance + investmentValue const negProfitPoints = getPoints('negProfit', portfolioHistory)
const profit = value - totalDeposits
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 ( return (
<div <div
className="w-full overflow-hidden" 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 <ResponsiveLine
margin={{ top: 10, right: 0, left: 40, bottom: 10 }}
data={data} data={data}
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: points[0]?.x, min: valuePoints[0]?.x,
max: endDate, max: endDate,
}} }}
yScale={{ yScale={{
type: 'linear', type: 'linear',
stacked: false, stacked: false,
min: Math.min(...points.map((p) => p.y)), min: yMin,
max: yMax,
}} }}
gridYValues={numYTickValues}
curve="stepAfter" curve="stepAfter"
enablePoints={false} enablePoints={false}
colors={{ datum: 'color' }} colors={{ datum: 'color' }}
axisBottom={{ axisBottom={{
tickValues: numXTickValues, tickValues: 0,
format: (time) => formatTime(+time, !!includeTime),
}} }}
pointBorderColor="#fff" pointBorderColor="#fff"
pointSize={points.length > 100 ? 0 : 6} pointSize={valuePoints.length > 100 ? 0 : 6}
axisLeft={{ axisLeft={{
tickValues: numYTickValues, tickValues: numYTickValues,
format: (value) => formatMoney(value), format: '.3s',
}} }}
enableGridX={!!width && width >= 800} enableGridX={false}
enableGridY={true} enableGridY={true}
gridYValues={numYTickValues}
enableSlices="x" enableSlices="x"
animate={false} animate={false}
yFormat={(value) => formatMoney(+value)} 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> ></ResponsiveLine>
</div> </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
}

View File

@ -1,11 +1,12 @@
import clsx from 'clsx'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { last } from 'lodash' import { last } from 'lodash'
import { memo, useRef, useState } from 'react' import { memo, useRef, useState } from 'react'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users' import { Period } from 'web/lib/firebase/users'
import { PillButton } from '../buttons/pill-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import { PortfolioValueGraph } from './portfolio-value-graph' import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo( export const PortfolioValueSection = memo(
@ -14,6 +15,13 @@ export const PortfolioValueSection = memo(
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value')
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
number | string | null
>(null)
const handleGraphDisplayChange = (num: string | number | null) => {
setGraphDisplayNumber(num)
}
// Remember the last defined portfolio history. // Remember the last defined portfolio history.
const portfolioRef = useRef(portfolioHistory) const portfolioRef = useRef(portfolioHistory)
@ -28,43 +36,144 @@ export const PortfolioValueSection = memo(
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
const totalValue = balance + investmentValue const totalValue = balance + investmentValue
const totalProfit = totalValue - totalDeposits const totalProfit = totalValue - totalDeposits
return ( return (
<> <>
<Row className="gap-8"> <Row className="mb-2 justify-between">
<Col className="flex-1 justify-center"> <Row className="gap-4 sm:gap-8">
<div className="text-sm text-gray-500">Profit</div> <Col
<div className="text-lg">{formatMoney(totalProfit)}</div> className={clsx(
</Col> 'cursor-pointer',
<select graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
className="select select-bordered self-start" )}
value={portfolioPeriod} onClick={() => setGraphMode('value')}
onChange={(e) => { >
setPortfolioPeriod(e.target.value as Period) <div className="text-greyscale-6 text-xs sm:text-sm">
}} Portfolio value
> </div>
<option value="allTime">All time</option> <div className={clsx('text-lg text-indigo-600 sm:text-xl')}>
<option value="monthly">Last Month</option> {graphMode === 'value'
<option value="weekly">Last 7d</option> ? graphDisplayNumber
<option value="daily">Last 24h</option> ? graphDisplayNumber
</select> : 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> </Row>
<PortfolioValueGraph <PortfolioValueGraph
portfolioHistory={currPortfolioHistory} portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'} mode={graphMode}
mode="profit" handleGraphDisplayChange={handleGraphDisplayChange}
/> />
<Spacer h={8} /> <PortfolioPeriodSelection
<Col className="flex-1 justify-center"> portfolioPeriod={portfolioPeriod}
<div className="text-sm text-gray-500">Portfolio value</div> setPortfolioPeriod={setPortfolioPeriod}
<div className="text-lg">{formatMoney(totalValue)}</div> className="border-greyscale-2 mt-2 gap-4 border-b"
</Col> selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
mode="value"
/> />
</> </>
) )
} }
) )
export function PortfolioPeriodSelection(props: {
setPortfolioPeriod: (string: any) => void
portfolioPeriod: string
className?: string
selectClassName?: string
}) {
const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } =
props
return (
<Row className={clsx(className, 'text-greyscale-4')}>
<button
className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('daily' as Period)}
>
1D
</button>
<button
className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('weekly' as Period)}
>
1W
</button>
<button
className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('monthly' as Period)}
>
1M
</button>
<button
className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')}
onClick={() => setPortfolioPeriod('allTime' as Period)}
>
ALL
</button>
</Row>
)
}
export function GraphToggle(props: {
setGraphMode: (mode: 'profit' | 'value') => void
graphMode: string
}) {
const { setGraphMode, graphMode } = props
return (
<Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton
selected={graphMode === 'value'}
onSelect={() => {
setGraphMode('value')
}}
xs={true}
className="z-50"
>
Value
</PillButton>
<PillButton
selected={graphMode === 'profit'}
onSelect={() => {
setGraphMode('profit')
}}
xs={true}
className="z-50"
>
Profit
</PillButton>
</Row>
)
}

View File

@ -10,15 +10,15 @@ import { XIcon } from '@heroicons/react/outline'
import { unLikeContract } from 'web/lib/firebase/likes' import { unLikeContract } from 'web/lib/firebase/likes'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
export function UserLikesButton(props: { user: User }) { export function UserLikesButton(props: { user: User; className?: string }) {
const { user } = props const { user, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const likedContracts = useUserLikedContracts(user.id) const likedContracts = useUserLikedContracts(user.id)
return ( return (
<> <>
<TextButton onClick={() => setIsOpen(true)}> <TextButton onClick={() => setIsOpen(true)} className={className}>
<span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '} <span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '}
Likes Likes
</TextButton> </TextButton>

View File

@ -13,14 +13,18 @@ import { getUser, updateUser } from 'web/lib/firebase/users'
import { TextButton } from 'web/components/text-button' import { TextButton } from 'web/components/text-button'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
export function ReferralsButton(props: { user: User; currentUser?: User }) { export function ReferralsButton(props: {
const { user, currentUser } = props user: User
currentUser?: User
className?: string
}) {
const { user, currentUser, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const referralIds = useReferrals(user.id) const referralIds = useReferrals(user.id)
return ( return (
<> <>
<TextButton onClick={() => setIsOpen(true)}> <TextButton onClick={() => setIsOpen(true)} className={className}>
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '} <span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
Referrals Referrals
</TextButton> </TextButton>

View File

@ -1,8 +1,13 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { NextRouter, useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid' 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 { User } from 'web/lib/firebase/users'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
@ -24,39 +29,23 @@ import { FollowersButton, FollowingButton } from './following-button'
import { UserFollowButton } from './follow-button' import { UserFollowButton } from './follow-button'
import { GroupsButton } from 'web/components/groups/groups-button' import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { import {
BettingStreakModal, BettingStreakModal,
hasCompletedStreakToday, hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal' } from 'web/components/profile/betting-streak-modal'
import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal' 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 }) { export function UserPage(props: { user: User }) {
const { user } = props const { user } = props
const router = useRouter() const router = useRouter()
const currentUser = useUser() const currentUser = useUser()
const isCurrentUser = user.id === currentUser?.id const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => { useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes' const claimedMana = router.query['claimed-mana'] === 'yes'
const showBettingStreak = router.query['show'] === 'betting-streak' setShowConfetti(claimedMana)
setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
const query = { ...router.query } const query = { ...router.query }
if (query.claimedMana || query.show) { if (query.claimedMana || query.show) {
delete query['claimed-mana'] delete query['claimed-mana']
@ -85,218 +74,159 @@ export function UserPage(props: { user: User }) {
{showConfetti && ( {showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} /> <FullscreenConfetti recycle={false} numberOfPieces={300} />
)} )}
<BettingStreakModal <Col className="relative">
isOpen={showBettingStreakModal} <Row className="relative px-4 pt-4">
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">
<Avatar <Avatar
username={user.username} username={user.username}
avatarUrl={user.avatarUrl} avatarUrl={user.avatarUrl}
size={24} 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 && ( {isCurrentUser && (
<SiteLink className="btn-sm btn" href="/profile"> <div className="absolute z-50 ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300">
<PencilIcon className="h-5 w-5" />{' '} <SiteLink href="/profile">
<div className="ml-2">Edit</div> <PencilIcon className="h-5" />{' '}
</SiteLink> </SiteLink>
</div>
)} )}
</div>
</div>
{/* Profile details: name, username, bio, and link to twitter/discord */} <Col className="w-full gap-4 pl-5">
<Col className="mx-4 -mt-6"> <div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
<Row className={'flex-wrap justify-between gap-y-2'}> <Col>
<Col> <span className="break-anywhere text-lg font-bold sm:text-2xl">
<span className="break-anywhere text-2xl font-bold"> {user.name}
{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)}
</span> </span>
<span>profit</span> <span className="sm:text-md text-greyscale-4 text-sm">
</Col> @{user.username}
<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> </span>
<span>next loan</span>
</Col> </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> </Col>
</Row> </Row>
<Spacer h={4} /> <Col className="mx-4 mt-2">
{user.bio && ( <Spacer h={1} />
<> <ProfilePublicStats
<div> className="text-greyscale-6 text-sm md:hidden"
<Linkify text={user.bio}></Linkify> user={user}
</div> />
<Spacer h={4} /> <Spacer h={1} />
</> {user.bio && (
)} <>
{(user.website || user.twitterHandle || user.discordHandle) && ( <div className="sm:text-md mt-2 text-sm sm:mt-0">
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4"> <Linkify text={user.bio}></Linkify>
{user.website && ( </div>
<SiteLink <Spacer h={2} />
href={ </>
'https://' + )}
user.website.replace('http://', '').replace('https://', '') {(user.website || user.twitterHandle || user.discordHandle) && (
} <Row className="mb-2 flex-wrap items-center gap-2 sm:gap-4">
> {user.website && (
<Row className="items-center gap-1"> <SiteLink
<LinkIcon className="h-4 w-4" /> href={
<span className="text-sm text-gray-500">{user.website}</span> 'https://' +
</Row> user.website.replace('http://', '').replace('https://', '')
</SiteLink> }
)} >
<Row className="items-center gap-1">
{user.twitterHandle && ( <LinkIcon className="h-4 w-4" />
<SiteLink <span className="text-greyscale-4 text-sm">
href={`https://twitter.com/${user.twitterHandle {user.website}
.replace('https://www.twitter.com/', '') </span>
.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} />
</Row> </Row>
<PortfolioValueSection userId={user.id} /> </SiteLink>
</Col> )}
),
}, {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> </Col>
</Page> </Page>
) )
@ -314,3 +244,88 @@ export function defaultBannerUrl(userId: string) {
] ]
return defaultBanner[genHash(userId)() % defaultBanner.length] 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>
)
}

View File

@ -4,8 +4,12 @@ import React from 'react'
import { Row } from './layout/row' import { Row } from './layout/row'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { ExclamationIcon } from '@heroicons/react/solid' import { ExclamationIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
export function WarningConfirmationButton(props: { export function WarningConfirmationButton(props: {
amount: number | undefined
outcome?: 'YES' | 'NO' | undefined
marketType: 'freeResponse' | 'binary'
warning?: string warning?: string
onSubmit: () => void onSubmit: () => void
disabled?: boolean disabled?: boolean
@ -14,26 +18,36 @@ export function WarningConfirmationButton(props: {
submitButtonClassName?: string submitButtonClassName?: string
}) { }) {
const { const {
amount,
onSubmit, onSubmit,
warning, warning,
disabled, disabled,
isSubmitting, isSubmitting,
openModalButtonClass, openModalButtonClass,
submitButtonClassName, submitButtonClassName,
outcome,
marketType,
} = props } = props
if (!warning) { if (!warning) {
return ( return (
<button <button
className={clsx( className={clsx(
openModalButtonClass, openModalButtonClass,
isSubmitting ? 'loading' : '', isSubmitting ? 'loading btn-disabled' : '',
disabled && 'btn-disabled' disabled && 'btn-disabled',
marketType === 'binary'
? !outcome
? 'btn-disabled bg-greyscale-2'
: ''
: ''
)} )}
onClick={onSubmit} onClick={onSubmit}
disabled={disabled}
> >
{isSubmitting ? 'Submitting...' : 'Submit'} {isSubmitting
? 'Submitting...'
: amount
? `Wager ${formatMoney(amount)}`
: 'Wager'}
</button> </button>
) )
} }
@ -45,7 +59,7 @@ export function WarningConfirmationButton(props: {
openModalButtonClass, openModalButtonClass,
isSubmitting && 'btn-disabled loading' isSubmitting && 'btn-disabled loading'
), ),
label: 'Submit', label: amount ? `Wager ${formatMoney(amount)}` : 'Wager',
}} }}
cancelBtn={{ cancelBtn={{
label: 'Cancel', label: 'Cancel',

View File

@ -35,10 +35,11 @@ export function YesNoSelector(props: {
<button <button
className={clsx( className={clsx(
commonClassNames, commonClassNames,
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
selected == 'YES' selected == 'YES'
? 'bg-primary text-white' ? 'border-teal-500 bg-teal-500 text-white'
: 'text-primary bg-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 btnClassName
)} )}
onClick={() => onSelect('YES')} onClick={() => onSelect('YES')}
@ -52,10 +53,11 @@ export function YesNoSelector(props: {
<button <button
className={clsx( className={clsx(
commonClassNames, commonClassNames,
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
selected == 'NO' selected == 'NO'
? 'bg-red-400 text-white' ? 'border-red-400 bg-red-400 text-white'
: 'bg-white text-red-400', : 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 btnClassName
)} )}
onClick={() => onSelect('NO')} onClick={() => onSelect('NO')}

View File

@ -9,12 +9,10 @@ import {
getUserBetContractsQuery, getUserBetContractsQuery,
listAllContracts, listAllContracts,
trendingContractsQuery, trendingContractsQuery,
getContractsQuery,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { QueryClient, useQuery, useQueryClient } from 'react-query' import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS, sleep } from 'common/util/time'
import { query, limit } from 'firebase/firestore' import { query, limit } from 'firebase/firestore'
import { Sort } from 'web/components/contract-search'
import { dailyScoreIndex } from 'web/lib/service/algolia' import { dailyScoreIndex } from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash' import { zipObject } from 'lodash'
@ -66,19 +64,6 @@ export const useTrendingContracts = (maxContracts: number) => {
return result.data return result.data
} }
export const useContractsQuery = (
sort: Sort,
maxContracts: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const result = useFirestoreQueryData(
['contracts-query', sort, maxContracts, filters],
getContractsQuery(sort, maxContracts, filters, visibility)
)
return result.data
}
export const useInactiveContracts = () => { export const useInactiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>() const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -101,7 +86,7 @@ export const usePrefetchUserBetContracts = (userId: string) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return queryClient.prefetchQuery( return queryClient.prefetchQuery(
['contracts', 'bets', userId], ['contracts', 'bets', userId],
() => getUserBetContracts(userId), () => sleep(1000).then(() => getUserBetContracts(userId)),
{ staleTime: 5 * MINUTE_MS } { staleTime: 5 * MINUTE_MS }
) )
} }

View File

@ -1,7 +1,13 @@
import { useWindowSize } from 'web/hooks/use-window-size' import { useEffect, useState } from 'react'
// matches talwind sm breakpoint export function useIsMobile(threshold?: number) {
export function useIsMobile() { const [isMobile, setIsMobile] = useState<boolean>()
const { width } = useWindowSize() useEffect(() => {
return (width ?? 0) < 640 // 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
} }

View File

@ -1,6 +1,6 @@
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DAY_MS, HOUR_MS, MINUTE_MS } from 'common/util/time' import { DAY_MS, HOUR_MS, MINUTE_MS, sleep } from 'common/util/time'
import { import {
getPortfolioHistory, getPortfolioHistory,
getPortfolioHistoryQuery, getPortfolioHistoryQuery,
@ -17,7 +17,7 @@ export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
const cutoff = getCutoff(period) const cutoff = getCutoff(period)
return queryClient.prefetchQuery( return queryClient.prefetchQuery(
['portfolio-history', userId, cutoff], ['portfolio-history', userId, cutoff],
() => getPortfolioHistory(userId, cutoff), () => sleep(1000).then(() => getPortfolioHistory(userId, cutoff)),
{ staleTime: 15 * MINUTE_MS } { staleTime: 15 * MINUTE_MS }
) )
} }

View File

@ -11,3 +11,29 @@ export const usePost = (postId: string | undefined) => {
return post return post
} }
export const usePosts = (postIds: string[]) => {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
if (postIds.length === 0) return
setPosts([])
const unsubscribes = postIds.map((postId) =>
listenForPost(postId, (post) => {
if (post) {
setPosts((posts) => [...posts, post])
}
})
)
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe())
}
}, [postIds])
return posts
.filter(
(post, index, self) => index === self.findIndex((t) => t.id === post.id)
)
.sort((a, b) => b.createdTime - a.createdTime)
}

View File

@ -1,5 +1,4 @@
import { usePrefetchUserBetContracts } from './use-contracts' import { usePrefetchUserBetContracts } from './use-contracts'
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
import { usePrefetchUserBets } from './use-user-bets' import { usePrefetchUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) { export function usePrefetch(userId: string | undefined) {
@ -7,6 +6,5 @@ export function usePrefetch(userId: string | undefined) {
return Promise.all([ return Promise.all([
usePrefetchUserBets(maybeUserId), usePrefetchUserBets(maybeUserId),
usePrefetchUserBetContracts(maybeUserId), usePrefetchUserBetContracts(maybeUserId),
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
]) ])
} }

View File

@ -41,7 +41,7 @@ export const useProbChanges = (
const hits = uniqBy( const hits = uniqBy(
[...positiveChanges.hits, ...negativeChanges.hits], [...positiveChanges.hits, ...negativeChanges.hits],
(c) => c.id (c) => c.id
) ).filter((c) => c.probChanges)
return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse() return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse()
} }

View File

@ -7,13 +7,13 @@ import {
getUserBetsQuery, getUserBetsQuery,
listenForUserContractBets, listenForUserContractBets,
} from 'web/lib/firebase/bets' } from 'web/lib/firebase/bets'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS, sleep } from 'common/util/time'
export const usePrefetchUserBets = (userId: string) => { export const usePrefetchUserBets = (userId: string) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return queryClient.prefetchQuery( return queryClient.prefetchQuery(
['bets', userId], ['bets', userId],
() => getUserBets(userId), () => sleep(1000).then(() => getUserBets(userId)),
{ staleTime: MINUTE_MS } { staleTime: MINUTE_MS }
) )
} }

View File

@ -90,6 +90,10 @@ export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params) return call(getFunctionUrl('getcurrentuser'), 'GET', params)
} }
export function createPost(params: { title: string; content: JSONContent }) { export function createPost(params: {
title: string
content: JSONContent
groupId?: string
}) {
return call(getFunctionUrl('createpost'), 'POST', params) return call(getFunctionUrl('createpost'), 'POST', params)
} }

View File

@ -24,7 +24,6 @@ import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { getBinaryProb } from 'common/contract-details' import { getBinaryProb } from 'common/contract-details'
import { Sort } from 'web/components/contract-search'
export const contracts = coll<Contract>('contracts') export const contracts = coll<Contract>('contracts')
@ -321,51 +320,6 @@ export const getTopGroupContracts = async (
return await getValues<Contract>(creatorContractsQuery) return await getValues<Contract>(creatorContractsQuery)
} }
const sortToField = {
newest: 'createdTime',
score: 'popularityScore',
'most-traded': 'volume',
'24-hour-vol': 'volume24Hours',
'prob-change-day': 'probChanges.day',
'last-updated': 'lastUpdated',
liquidity: 'totalLiquidity',
'close-date': 'closeTime',
'resolve-date': 'resolutionTime',
'prob-descending': 'prob',
'prob-ascending': 'prob',
} as const
const sortToDirection = {
newest: 'desc',
score: 'desc',
'most-traded': 'desc',
'24-hour-vol': 'desc',
'prob-change-day': 'desc',
'last-updated': 'desc',
liquidity: 'desc',
'close-date': 'asc',
'resolve-date': 'desc',
'prob-ascending': 'asc',
'prob-descending': 'desc',
} as const
export const getContractsQuery = (
sort: Sort,
maxItems: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const { groupSlug } = filters
return query(
contracts,
where('isResolved', '==', false),
...(visibility ? [where('visibility', '==', visibility)] : []),
...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []),
orderBy(sortToField[sort], sortToDirection[sort]),
limit(maxItems)
)
}
export const getRecommendedContracts = async ( export const getRecommendedContracts = async (
contract: Contract, contract: Contract,
excludeBettorId: string, excludeBettorId: string,

View File

@ -43,6 +43,7 @@ export function groupPath(
| 'about' | 'about'
| typeof GROUP_CHAT_SLUG | typeof GROUP_CHAT_SLUG
| 'leaderboards' | 'leaderboards'
| 'posts'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }

View File

@ -39,3 +39,8 @@ export function listenForPost(
) { ) {
return listenForValue(doc(posts, postId), setPost) return listenForValue(doc(posts, postId), setPost)
} }
export async function listPosts(postIds?: string[]) {
if (postIds === undefined) return []
return Promise.all(postIds.map(getPost))
}

View File

@ -9,9 +9,6 @@ module.exports = {
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,
experimental: { experimental: {
images: {
allowFutureImage: true,
},
scrollRestoration: true, scrollRestoration: true,
externalDir: true, externalDir: true,
modularizeImports: { modularizeImports: {

View File

@ -23,9 +23,9 @@
"@floating-ui/react-dom-interactions": "0.9.2", "@floating-ui/react-dom-interactions": "0.9.2",
"@headlessui/react": "1.6.1", "@headlessui/react": "1.6.1",
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.5",
"@nivo/core": "0.74.0", "@nivo/core": "0.80.0",
"@nivo/line": "0.74.0", "@nivo/line": "0.80.0",
"@nivo/tooltip": "0.74.0", "@nivo/tooltip": "0.80.0",
"@react-query-firebase/firestore": "0.4.2", "@react-query-firebase/firestore": "0.4.2",
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-character-count": "2.0.0-beta.31", "@tiptap/extension-character-count": "2.0.0-beta.31",
@ -58,6 +58,7 @@
"react-instantsearch-hooks-web": "6.24.1", "react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0", "react-query": "3.39.0",
"react-twitter-embed": "4.0.4", "react-twitter-embed": "4.0.4",
"react-masonry-css": "1.0.16",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"tippy.js": "6.3.7" "tippy.js": "6.3.7"
}, },

View File

@ -1,93 +0,0 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
export default function CreatePost() {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<Page>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
</Page>
)
}

View File

@ -16,7 +16,7 @@ import {
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserById } from 'web/hooks/use-user'
import { import {
useGroup, useGroup,
useGroupContractIds, useGroupContractIds,
@ -42,10 +42,10 @@ import { GroupComment } from 'common/comment'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { GroupAboutPost } from 'web/components/groups/group-about-post' import { GroupAboutPost } from 'web/components/groups/group-about-post'
import { getPost } from 'web/lib/firebase/posts' import { getPost, listPosts, postPath } from 'web/lib/firebase/posts'
import { Post } from 'common/post' import { Post } from 'common/post'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post' import { usePost, usePosts } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { ArrowLeftIcon } from '@heroicons/react/solid' import { ArrowLeftIcon } from '@heroicons/react/solid'
@ -53,6 +53,10 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { Avatar } from 'web/components/avatar'
import { Title } from 'web/components/title'
import { fromNow } from 'web/lib/util/time'
import { CreatePost } from 'web/components/create-post'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -70,7 +74,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
? 'all' ? 'all'
: 'open' : 'open'
const aboutPost = const aboutPost =
group && group.aboutPostId != null && (await getPost(group.aboutPostId)) group && group.aboutPostId != null ? await getPost(group.aboutPostId) : null
const messages = group && (await listAllCommentsOnGroup(group.id)) const messages = group && (await listAllCommentsOnGroup(group.id))
const cachedTopTraderIds = const cachedTopTraderIds =
@ -83,6 +88,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const creator = await creatorPromise const creator = await creatorPromise
const posts = ((group && (await listPosts(group.postIds))) ?? []).filter(
(p) => p != null
) as Post[]
return { return {
props: { props: {
group, group,
@ -93,6 +101,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
messages, messages,
aboutPost, aboutPost,
suggestedFilter, suggestedFilter,
posts,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -107,17 +116,19 @@ const groupSubpages = [
'markets', 'markets',
'leaderboards', 'leaderboards',
'about', 'about',
'posts',
] as const ] as const
export default function GroupPage(props: { export default function GroupPage(props: {
group: Group | null group: Group | null
memberIds: string[] memberIds: string[]
creator: User creator: User | null
topTraders: { user: User; score: number }[] topTraders: { user: User; score: number }[]
topCreators: { user: User; score: number }[] topCreators: { user: User; score: number }[]
messages: GroupComment[] messages: GroupComment[]
aboutPost: Post aboutPost: Post | null
suggestedFilter: 'open' | 'all' suggestedFilter: 'open' | 'all'
posts: Post[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
group: null, group: null,
@ -127,26 +138,36 @@ export default function GroupPage(props: {
topCreators: [], topCreators: [],
messages: [], messages: [],
suggestedFilter: 'open', suggestedFilter: 'open',
posts: [],
} }
const { creator, topTraders, topCreators, suggestedFilter } = props const { creator, topTraders, topCreators, suggestedFilter, posts } = props
const router = useRouter() const router = useRouter()
const { slugs } = router.query as { slugs: string[] } const { slugs } = router.query as { slugs: string[] }
const page = slugs?.[1] as typeof groupSubpages[number] const page = slugs?.[1] as typeof groupSubpages[number]
const tabIndex = ['markets', 'leaderboard', 'about', 'posts'].indexOf(
page ?? 'markets'
)
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost
let groupPosts = usePosts(group?.postIds ?? []) ?? posts
if (aboutPost != null) {
groupPosts = [aboutPost, ...groupPosts]
}
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrerUsername: creator.username, defaultReferrerUsername: creator?.username,
groupId: group?.id, groupId: group?.id,
}) })
if (group === null || !groupSubpages.includes(page) || slugs[2]) { if (group === null || !groupSubpages.includes(page) || slugs[2] || !creator) {
return <Custom404 /> return <Custom404 />
} }
const isCreator = user && group && user.id === group.creatorId const isCreator = user && group && user.id === group.creatorId
@ -172,6 +193,16 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const postsPage = (
<>
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{posts && <GroupPosts posts={groupPosts} group={group} />}
</div>
</Col>
</>
)
const aboutTab = ( const aboutTab = (
<Col> <Col>
{(group.aboutPostId != null || isCreator || isAdmin) && ( {(group.aboutPostId != null || isCreator || isAdmin) && (
@ -234,6 +265,10 @@ export default function GroupPage(props: {
title: 'About', title: 'About',
content: aboutTab, content: aboutTab,
}, },
{
title: 'Posts',
content: postsPage,
},
] ]
return ( return (
@ -245,7 +280,8 @@ export default function GroupPage(props: {
/> />
<TopGroupNavBar group={group} /> <TopGroupNavBar group={group} />
<div className={'relative p-2 pt-0 md:pt-2'}> <div className={'relative p-2 pt-0 md:pt-2'}>
<Tabs className={'mb-2'} tabs={tabs} /> {/* TODO: Switching tabs should also update the group path */}
<Tabs className={'mb-2'} tabs={tabs} defaultIndex={tabIndex} />
</div> </div>
</Page> </Page>
) )
@ -413,6 +449,84 @@ function GroupLeaderboard(props: {
) )
} }
function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser()
const createPost = <CreatePost group={group} />
const postList = (
<div className=" align-start w-full items-start">
<Row className="flex justify-between">
<Col>
<Title text={'Posts'} className="!mt-0" />
</Col>
<Col>
{user && (
<Button
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post
</Button>
)}
</Col>
</Row>
<div className="mt-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</div>
)
return showCreatePost ? createPost : postList
}
function PostCard(props: { post: Post }) {
const { post } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
if (!user) return <> </>
return (
<div className="py-1">
<Link href={postPath(post.slug)}>
<Row
className={
'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100'
}
>
<div className="flex-shrink-0">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className="">
<div className="text-sm text-gray-500">
<UserLink
className="text-neutral"
name={user?.name}
username={user?.username}
/>
<span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div>
<div className="text-lg font-medium text-gray-900">
{post.title}
</div>
</div>
</Row>
</Link>
</div>
)
}
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)

View File

@ -102,6 +102,32 @@ export default function Groups(props: {
className="mb-4" className="mb-4"
currentPageForAnalytics={'groups'} currentPageForAnalytics={'groups'}
tabs={[ 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 ...(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> </Col>

View File

@ -45,6 +45,7 @@ import { Title } from 'web/components/title'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts' import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts'
import { ProfitBadge } from 'web/components/profit-badge' import { ProfitBadge } from 'web/components/profit-badge'
import { LoadingIndicator } from 'web/components/loading-indicator'
export default function Home() { export default function Home() {
const user = useUser() const user = useUser()
@ -54,6 +55,13 @@ export default function Home() {
useSaveReferral() useSaveReferral()
usePrefetch(user?.id) usePrefetch(user?.id)
useEffect(() => {
if (user === null) {
// Go to landing page if not logged in.
Router.push('/')
}
})
const groups = useMemberGroupsSubscription(user) const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? []) const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? [])
@ -82,13 +90,17 @@ export default function Home() {
<DailyStats user={user} /> <DailyStats user={user} />
</Row> </Row>
<> {!user ? (
{sections.map((section) => <LoadingIndicator />
renderSection(section, user, groups, groupContracts) ) : (
)} <>
{sections.map((section) =>
renderSection(section, user, groups, groupContracts)
)}
<TrendingGroupsSection user={user} /> <TrendingGroupsSection user={user} />
</> </>
)}
</Col> </Col>
<button <button
type="button" type="button"
@ -106,9 +118,9 @@ export default function Home() {
const HOME_SECTIONS = [ const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' }, { label: 'Daily movers', id: 'daily-movers' },
{ label: 'Daily trending', id: 'daily-trending' },
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'New', id: 'newest' }, { label: 'New', id: 'newest' },
{ label: 'Recently updated', id: 'recently-updated-for-you' },
] ]
export const getHomeItems = (groups: Group[], sections: string[]) => { export const getHomeItems = (groups: Group[], sections: string[]) => {
@ -127,6 +139,10 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
const sectionItems = filterDefined(sections.map((id) => itemsById[id])) const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
// Add new home section items to the top.
sectionItems.unshift(
...HOME_SECTIONS.filter((item) => !sectionItems.includes(item))
)
// Add unmentioned items to the end. // Add unmentioned items to the end.
sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
@ -138,20 +154,20 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
function renderSection( function renderSection(
section: { id: string; label: string }, section: { id: string; label: string },
user: User | null | undefined, user: User,
groups: Group[] | undefined, groups: Group[] | undefined,
groupContracts: Dictionary<CPMMBinaryContract[]> | undefined groupContracts: Dictionary<CPMMBinaryContract[]> | undefined
) { ) {
const { id, label } = section const { id, label } = section
if (id === 'daily-movers') { if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} /> return <DailyMoversSection key={id} userId={user.id} />
} }
if (id === 'recently-updated-for-you') if (id === 'daily-trending')
return ( return (
<SearchSection <SearchSection
key={id} key={id}
label={label} label={label}
sort={'last-updated'} sort={'daily-score'}
pill="personal" pill="personal"
user={user} user={user}
/> />
@ -210,7 +226,7 @@ function SectionHeader(props: {
function SearchSection(props: { function SearchSection(props: {
label: string label: string
user: User | null | undefined | undefined user: User
sort: Sort sort: Sort
pill?: string pill?: string
}) { }) {
@ -236,7 +252,7 @@ function SearchSection(props: {
function GroupSection(props: { function GroupSection(props: {
group: Group group: Group
user: User | null | undefined | undefined user: User
contracts: CPMMBinaryContract[] contracts: CPMMBinaryContract[]
}) { }) {
const { group, user, contracts } = props const { group, user, contracts } = props
@ -247,18 +263,16 @@ function GroupSection(props: {
<Button <Button
color="gray-white" color="gray-white"
onClick={() => { onClick={() => {
if (user) { const homeSections = (user.homeSections ?? []).filter(
const homeSections = (user.homeSections ?? []).filter( (id) => id !== group.id
(id) => id !== group.id )
) updateUser(user.id, { homeSections })
updateUser(user.id, { homeSections })
toast.promise(leaveGroup(group, user.id), { toast.promise(leaveGroup(group, user.id), {
loading: 'Unfollowing group...', loading: 'Unfollowing group...',
success: `Unfollowed ${group.name}`, success: `Unfollowed ${group.name}`,
error: "Couldn't unfollow group, try again?", error: "Couldn't unfollow group, try again?",
}) })
}
}} }}
> >
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" /> <XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />

View File

@ -971,6 +971,8 @@ function ContractResolvedNotification(props: {
const { sourceText, data } = notification const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market' const subtitle = 'resolved the market'
const profitable = userPayout >= userInvestment
const ROI = (userPayout - userInvestment) / userInvestment
const resolutionDescription = () => { const resolutionDescription = () => {
if (!sourceText) return <div /> if (!sourceText) return <div />
@ -1002,23 +1004,21 @@ function ContractResolvedNotification(props: {
const description = const description =
userInvestment && userPayout !== undefined ? ( userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}> <>
Resolved: {resolutionDescription()} Resolved: {resolutionDescription()} Invested:
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span> <span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout: Payout:
<span <span
className={clsx( className={clsx(
userPayout > 0 ? 'text-primary' : 'text-red-500', profitable ? 'text-primary' : 'text-red-500',
'truncate' 'truncate text-ellipsis'
)} )}
> >
{formatMoney(userPayout)} {formatMoney(userPayout)}
{` (${userPayout > 0 ? '+' : ''}${Math.round( {userPayout > 0 &&
((userPayout - userInvestment) / userInvestment) * 100 ` (${profitable ? '+' : ''}${Math.round(ROI * 100)}%)`}
)}%)`}
</span> </span>
</Row> </>
) : ( ) : (
<span>Resolved {resolutionDescription()}</span> <span>Resolved {resolutionDescription()}</span>
) )
@ -1038,9 +1038,7 @@ function ContractResolvedNotification(props: {
highlighted={highlighted} highlighted={highlighted}
subtitle={subtitle} subtitle={subtitle}
> >
<Row> <Row className={'line-clamp-2 space-x-1'}>{description}</Row>
<span>{description}</span>
</Row>
</NotificationFrame> </NotificationFrame>
) )
} }

View File

@ -13,7 +13,6 @@ import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { defaultBannerUrl } from 'web/components/user-page'
import { generateNewApiKey } from 'web/lib/api/api-key' import { generateNewApiKey } from 'web/lib/api/api-key'
import { changeUserInfo } from 'web/lib/firebase/api' import { changeUserInfo } from 'web/lib/firebase/api'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
@ -176,27 +175,6 @@ export default function ProfilePage(props: {
onBlur={updateUsername} onBlur={updateUsername}
/> />
</div> </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'], ['bio', 'Bio'],

View File

@ -107,7 +107,14 @@ const tourneys: Tourney[] = [
groupId: 'SxGRqXRpV3RAQKudbcNb', 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', title: 'SF 2022 Ballot',
blurb: 'Which ballot initiatives will pass this year in SF and CA?', blurb: 'Which ballot initiatives will pass this year in SF and CA?',

View File

@ -146,7 +146,8 @@ function TwitchPlaysManifoldMarkets(props: {
<div> <div>
Instead of Twitch channel points we use our own play money, mana (M$). 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 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> </div>
</Col> </Col>
</div> </div>
@ -176,35 +177,47 @@ function TwitchChatCommands() {
<Col className="gap-4"> <Col className="gap-4">
<Subtitle text="For Chat" /> <Subtitle text="For Chat" />
<Command <Command
command="bet yes #" command="y#"
desc="Bets an amount of M$ on yes, for example !bet yes 20" 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
command="sell" command="sell"
desc="Sells all shares you own. Using this command causes you to desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable cash out early based on the current probability.
(if the probability has moved towards the direction you bet) or cause 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."
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."
/> />
<Command command="balance" desc="Shows how much M$ you have." /> <Command
<Command command="allin yes" desc="Bets your entire balance on yes." /> command="position"
<Command command="allin no" desc="Bets your entire balance on no." /> 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" /> <div className="mb-4" />
<Subtitle text="For Mods/Streamer" /> <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
command="create <question>" command="create [question]"
desc="Creates and features the question. Be careful... this will override any question that is currently featured." 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 yes" desc="Resolves the market as 'Yes'." />
<Command command="resolve no" desc="Resolves the market as 'No'." /> <Command command="resolve no" desc="Resolves the market as 'No'." />
<Command <Command
command="resolve n/a" command="resolve na"
desc="Resolves the market as 'N/A' and refunds everyone their mana." 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> </Col>
</div> </div>
@ -384,8 +397,8 @@ function SetUpBot(props: {
buttonOnClick={copyOverlayLink} buttonOnClick={copyOverlayLink}
> >
Create a new browser source in your streaming software such as OBS. 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 Paste in the above link and type in the desired size. We recommend
setting the size to 400x400. 450x375.
</BotSetupStep> </BotSetupStep>
<BotSetupStep <BotSetupStep
stepNum={3} stepNum={3}
@ -397,6 +410,10 @@ function SetUpBot(props: {
your OBS as a custom dock. your OBS as a custom dock.
</BotSetupStep> </BotSetupStep>
</div> </div>
<div>
Need help? Contact SirSalty#5770 in Discord or email
david@manifold.markets
</div>
</Col> </Col>
</> </>
) )

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

View File

@ -15,10 +15,8 @@ module.exports = {
} }
), ),
extend: { extend: {
backgroundImage: {
'world-trading': "url('/world-trading-background.webp')",
},
colors: { colors: {
'red-25': '#FDF7F6',
'greyscale-1': '#FBFBFF', 'greyscale-1': '#FBFBFF',
'greyscale-2': '#E7E7F4', 'greyscale-2': '#E7E7F4',
'greyscale-3': '#D8D8EB', 'greyscale-3': '#D8D8EB',

394
yarn.lock
View File

@ -2553,102 +2553,99 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136"
integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA== integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA==
"@nivo/annotations@0.74.0": "@nivo/annotations@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.74.0.tgz#f4a3474fdf8812c3812c30e08d3277e209bec0f6" resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.80.0.tgz#127e4801fff7370dcfb9acfe1e335781dd65cfd5"
integrity sha512-nxZLKDi9YEy2zZUsOtbYL/2oAgsxK5SVZ1P3Csll+cQ96uLU6sU7jmb67AwK0nDbYk7BD3sZf/O/A9r/MCK4Ow== integrity sha512-bC9z0CLjU07LULTMWsqpjovRtHxP7n8oJjqBQBLmHOGB4IfiLbrryBfu9+aEZH3VN2jXHhdpWUz+HxeZzOzsLg==
dependencies: dependencies:
"@nivo/colors" "0.74.0" "@nivo/colors" "0.80.0"
"@react-spring/web" "9.2.6" "@react-spring/web" "9.4.5"
lodash "^4.17.21" lodash "^4.17.21"
"@nivo/axes@0.74.0": "@nivo/axes@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.74.0.tgz#cf7cf2277b7aca5449a040ddf3e0cf9891971199" resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.80.0.tgz#22788855ddc45bb6a619dcd03d62d4bd8c0fc35f"
integrity sha512-27o1H+Br0AaeUTiRhy7OebqzYEWr1xznHOxd+Hn2Xz9kK1alGBiPgwXrkXV0Q9CtrsroQFnX2QR3JxRgOtC5fA== integrity sha512-AsUyaSHGwQVSEK8QXpsn8X+poZxvakLMYW7crKY1xTGPNw+SU4SSBohPVumm2jMH3fTSLNxLhAjWo71GBJXfdA==
dependencies: dependencies:
"@nivo/scales" "0.74.0" "@nivo/scales" "0.80.0"
"@react-spring/web" "9.2.6" "@react-spring/web" "9.4.5"
d3-format "^1.4.4" d3-format "^1.4.4"
d3-time-format "^3.0.0" d3-time-format "^3.0.0"
"@nivo/colors@0.74.0": "@nivo/colors@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.74.0.tgz#29d1e7c6f3bcab4e872a168651b3a90cfba03a4f" resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.80.0.tgz#5b70b4979df246d9d0d69fb638bba9764dd88b52"
integrity sha512-5ClckmBm3x2XdJqHMylr6erY+scEL/twoGVfyXak/L+AIhL+Gf9PQxyxyfl3Lbtc3SPeAQe0ZAO1+VrmTn7qlA== integrity sha512-T695Zr411FU4RPo7WDINOAn8f79DPP10SFJmDdEqELE+cbzYVTpXqLGZ7JMv88ko7EOf9qxLQgcBqY69rp9tHQ==
dependencies: dependencies:
d3-color "^2.0.0" d3-color "^2.0.0"
d3-scale "^3.2.3" d3-scale "^3.2.3"
d3-scale-chromatic "^2.0.0" d3-scale-chromatic "^2.0.0"
lodash "^4.17.21" lodash "^4.17.21"
react-motion "^0.5.2"
"@nivo/core@0.74.0": "@nivo/core@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.74.0.tgz#7634c78a36a8bd50a0c04c6b6f12b779a88ec2f4" resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.80.0.tgz#d180cb2622158eb7bc5f984131ff07984f12297e"
integrity sha512-LZ3kN1PiEW0KU4PTBgaHFO757amyKZkEL4mKdAzvyNQtpq5idB3OhC/sYrBxhJaLqYcX19MgNfhIel/0KygHAg== integrity sha512-6caih0RavXdWWSfde+rC2pk17WrX9YQlqK26BrxIdXzv3Ydzlh5SkrC7dR2TEvMGBhunzVeLOfiC2DWT1S8CFg==
dependencies: dependencies:
"@nivo/recompose" "0.74.0" "@nivo/recompose" "0.80.0"
"@react-spring/web" "9.2.6" "@react-spring/web" "9.4.5"
d3-color "^2.0.0" d3-color "^2.0.0"
d3-format "^1.4.4" d3-format "^1.4.4"
d3-hierarchy "^1.1.8"
d3-interpolate "^2.0.1" d3-interpolate "^2.0.1"
d3-scale "^3.2.3" d3-scale "^3.2.3"
d3-scale-chromatic "^2.0.0" d3-scale-chromatic "^2.0.0"
d3-shape "^1.3.5" d3-shape "^1.3.5"
d3-time-format "^3.0.0" d3-time-format "^3.0.0"
lodash "^4.17.21" lodash "^4.17.21"
resize-observer-polyfill "^1.5.1"
"@nivo/legends@0.74.0": "@nivo/legends@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.74.0.tgz#8e5e04b2a3f980c2073a394d94c4d89fa8bc8724" resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.80.0.tgz#49edc54000075b4df055f86794a8c32810269d06"
integrity sha512-Bfk392ngre1C8UaGoymwqK0acjjzuk0cglUSNsr0z8BAUQIVGUPthtfcxbq/yUYGJL/cxWky2QKxi9r3C0FbmA== integrity sha512-h0IUIPGygpbKIZZZWIxkkxOw4SO0rqPrqDrykjaoQz4CvL4HtLIUS3YRA4akKOVNZfS5agmImjzvIe0s3RvqlQ==
"@nivo/line@0.74.0": "@nivo/line@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.74.0.tgz#f1f430d64a81d2fe1a5fd49e5cfaa61242066927" resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.80.0.tgz#ba541b0fcfd53b3a7ce865feb43c993b7cf4a7d4"
integrity sha512-uJssLII1UTfxrZkPrkki054LFUpSKeqS35ttwK6VLvyqs5r3SrSXn223vDRNaaxuop5oT/L3APUJQwQDqUcj3w== integrity sha512-6UAD/y74qq3DDRnVb+QUPvXYojxMtwXMipGSNvCGk8omv1QZNTaUrbV+eQacvn9yh//a0yZcWipnpq0tGJyJCA==
dependencies: dependencies:
"@nivo/annotations" "0.74.0" "@nivo/annotations" "0.80.0"
"@nivo/axes" "0.74.0" "@nivo/axes" "0.80.0"
"@nivo/colors" "0.74.0" "@nivo/colors" "0.80.0"
"@nivo/legends" "0.74.0" "@nivo/legends" "0.80.0"
"@nivo/scales" "0.74.0" "@nivo/scales" "0.80.0"
"@nivo/tooltip" "0.74.0" "@nivo/tooltip" "0.80.0"
"@nivo/voronoi" "0.74.0" "@nivo/voronoi" "0.80.0"
"@react-spring/web" "9.2.6" "@react-spring/web" "9.4.5"
d3-shape "^1.3.5" d3-shape "^1.3.5"
"@nivo/recompose@0.74.0": "@nivo/recompose@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.74.0.tgz#057e8e1154073d7f4cb01aa8d165c3914b8bdb54" resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.80.0.tgz#572048aed793321a0bada1fd176b72df5a25282e"
integrity sha512-qC9gzGvDIxocrJoozDjqqffOwDpuEZijeMV59KExnztCwIpQbIYVBsDdpvL+tXfWausigSlnGILGfereXJTLUQ== integrity sha512-iL3g7j3nJGD9+mRDbwNwt/IXDXH6E29mhShY1I7SP91xrfusZV9pSFf4EzyYgruNJk/2iqMuaqn+e+TVFra44A==
dependencies: dependencies:
react-lifecycles-compat "^3.0.4" react-lifecycles-compat "^3.0.4"
"@nivo/scales@0.74.0": "@nivo/scales@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.74.0.tgz#ede12b899da9e3aee7921ebce40f227e670a430d" resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.80.0.tgz#39313fb97c8ae9633c2aa1e17adb57cb851e8a50"
integrity sha512-5mER71NgZGdgs8X2PgilBpAWMMGtTXrUuYOBQWDKDMgtc83MU+mphhiYfLv5e6ViZyUB5ebfEkfeIgStLqrcEA== integrity sha512-4y2pQdCg+f3n4TKXC2tYuq71veZM+xPRQbOTgGYJpuBvMc7pQsXF9T5z7ryeIG9hkpXkrlyjecU6XcAG7tLSNg==
dependencies: dependencies:
d3-scale "^3.2.3" d3-scale "^3.2.3"
d3-time "^1.0.11" d3-time "^1.0.11"
d3-time-format "^3.0.0" d3-time-format "^3.0.0"
lodash "^4.17.21" lodash "^4.17.21"
"@nivo/tooltip@0.74.0": "@nivo/tooltip@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.74.0.tgz#60d94b0fecc2fc179ada3efa380e7e456982b4a5" resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.80.0.tgz#07ebef47eb708a0612bd6297d5ad156bbec19d34"
integrity sha512-h3PUgNFF5HUeQFfx19MWS1uGK8iUDymZNY+5PyaCWDFT+0/ldXBu8uw5WYRui2KwNdTym6F0E/aT7JKczDd85w== integrity sha512-qGmrreRwnCsYjn/LAuwBtxBn/tvG8y+rwgd4gkANLBAoXd3bzJyvmkSe+QJPhUG64bq57ibDK+lO2pC48a3/fw==
dependencies: dependencies:
"@react-spring/web" "9.2.6" "@react-spring/web" "9.4.5"
"@nivo/voronoi@0.74.0": "@nivo/voronoi@0.80.0":
version "0.74.0" version "0.80.0"
resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.74.0.tgz#4b427955ddabd86934a2bbb95a62ff53ee97c575" resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.80.0.tgz#59cc7ed253dc1a5bbcf614a5ac37d2468d561599"
integrity sha512-Q3267T1+Tlufn8LbmSYnO8x9gL+h/iwH2Uqc5CENHSZu2KPD0PB82vxpQnbDVhjadulI0rlrPA9fU3VY3q1zKg== integrity sha512-zaJV3I3cRu1gHpsXCIEvp6GGlGY8P7D9CwAVCjYDGrz3W/+GKN0kA7qGyHTC97zVxJtfefxSPlP/GtOdxac+qw==
dependencies: dependencies:
d3-delaunay "^5.3.0" d3-delaunay "^5.3.0"
d3-scale "^3.2.3" d3-scale "^3.2.3"
@ -2747,50 +2744,51 @@
resolved "https://registry.yarnpkg.com/@react-query-firebase/firestore/-/firestore-0.4.2.tgz#6ae52768715aa0a5c0d903dd4fd953ed417ba635" resolved "https://registry.yarnpkg.com/@react-query-firebase/firestore/-/firestore-0.4.2.tgz#6ae52768715aa0a5c0d903dd4fd953ed417ba635"
integrity sha512-7eYp905+sfBRcBTdj7W7BAc3bI3V0D0kKca4/juOTnN4gyoNyaCNOCjLPY467dTq325hGs7BX0ol7Pw3JENdHA== integrity sha512-7eYp905+sfBRcBTdj7W7BAc3bI3V0D0kKca4/juOTnN4gyoNyaCNOCjLPY467dTq325hGs7BX0ol7Pw3JENdHA==
"@react-spring/animated@~9.2.6-beta.0": "@react-spring/animated@~9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.2.6.tgz#58f30fb75d8bfb7ccbc156cfd6b974a8f3dfd54e" resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.5.tgz#dd9921c716a4f4a3ed29491e0c0c9f8ca0eb1a54"
integrity sha512-xjL6nmixYNDvnpTs1FFMsMfSC0tURwPCU3b2jWNriYGLfwZ7c/TcyaEZA7yiNnmdFnuR3f3Z27AqIgaFC083Cw== integrity sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA==
dependencies: dependencies:
"@react-spring/shared" "~9.2.6-beta.0" "@react-spring/shared" "~9.4.5"
"@react-spring/types" "~9.2.6-beta.0" "@react-spring/types" "~9.4.5"
"@react-spring/core@~9.2.6-beta.0": "@react-spring/core@~9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.2.6.tgz#ae22338fe55d070caf03abb4293b5519ba620d93" resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.4.5.tgz#4616e1adc18dd10f5731f100ebdbe9518b89ba3c"
integrity sha512-uPHUxmu+w6mHJrfQTMtmGJ8iZEwiVxz9kH7dRyk69bkZJt9z+w0Oj3UF4J3VcECZsbm3HRhN2ogXSAaqGjwhQw== integrity sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ==
dependencies: dependencies:
"@react-spring/animated" "~9.2.6-beta.0" "@react-spring/animated" "~9.4.5"
"@react-spring/shared" "~9.2.6-beta.0" "@react-spring/rafz" "~9.4.5"
"@react-spring/types" "~9.2.6-beta.0" "@react-spring/shared" "~9.4.5"
"@react-spring/types" "~9.4.5"
"@react-spring/rafz@~9.2.6-beta.0": "@react-spring/rafz@~9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.2.6.tgz#d97484003875bf5fb5e6ec22dee97cc208363e48" resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.4.5.tgz#84f809f287f2a66bbfbc66195db340482f886bd7"
integrity sha512-62SivLKEpo7EfHPkxO5J3g9Cr9LF6+1A1RVOMJhkcpEYtbdbmma/d63Xp8qpMPEpk7uuWxaTb6jjyxW33pW3sg== integrity sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ==
"@react-spring/shared@~9.2.6-beta.0": "@react-spring/shared@~9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.2.6.tgz#2c84e62cc0cfbbbbeb5546acd46c1f4b248bc562" resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.4.5.tgz#4c3ad817bca547984fb1539204d752a412a6d829"
integrity sha512-Qrm9fopKG/RxZ3Rw+4euhrpnB3uXSyiON9skHbcBfmkkzagpkUR66MX1YLrhHw0UchcZuSDnXs0Lonzt1rpWag== integrity sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA==
dependencies: dependencies:
"@react-spring/rafz" "~9.2.6-beta.0" "@react-spring/rafz" "~9.4.5"
"@react-spring/types" "~9.2.6-beta.0" "@react-spring/types" "~9.4.5"
"@react-spring/types@~9.2.6-beta.0": "@react-spring/types@~9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.2.6.tgz#f60722fcf9f8492ae16d0bdc47f0ea3c2a16d2cf" resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.4.5.tgz#9c71e5ff866b5484a7ef3db822bf6c10e77bdd8c"
integrity sha512-l7mCw182DtDMnCI8CB9orgTAEoFZRtdQ6aS6YeEAqYcy3nQZPmPggIHH9DxyLw7n7vBPRSzu9gCvUMgXKpTflg== integrity sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg==
"@react-spring/web@9.2.6": "@react-spring/web@9.4.5":
version "9.2.6" version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.2.6.tgz#c4fba69e1b1b43bd1d6a62346530cfb07f2be09b" resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.4.5.tgz#b92f05b87cdc0963a59ee149e677dcaff09f680e"
integrity sha512-0HkRsEYR/CO3Uw46FWDWaF2wg2rUXcWE2R9AoZXthEYLUn5w9uE1mf2Jel7BxBxWGQ73owkqSQv+klA1Hb+ViQ== integrity sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA==
dependencies: dependencies:
"@react-spring/animated" "~9.2.6-beta.0" "@react-spring/animated" "~9.4.5"
"@react-spring/core" "~9.2.6-beta.0" "@react-spring/core" "~9.4.5"
"@react-spring/shared" "~9.2.6-beta.0" "@react-spring/shared" "~9.4.5"
"@react-spring/types" "~9.2.6-beta.0" "@react-spring/types" "~9.4.5"
"@rushstack/eslint-patch@^1.1.3": "@rushstack/eslint-patch@^1.1.3":
version "1.1.3" version "1.1.3"
@ -3566,6 +3564,13 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/yauzl@^2.9.1":
version "2.10.0"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@5.36.0": "@typescript-eslint/eslint-plugin@5.36.0":
version "5.36.0" version "5.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5"
@ -4318,7 +4323,7 @@ base16@^1.0.0:
resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70"
integrity sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ== integrity sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==
base64-js@^1.3.0: base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@ -4348,6 +4353,15 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
bl@^4.0.3:
version "4.1.0"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
dependencies:
buffer "^5.5.0"
inherits "^2.0.4"
readable-stream "^3.4.0"
bluebird@^3.7.1: bluebird@^3.7.1:
version "3.7.2" version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
@ -4461,6 +4475,11 @@ browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4
node-releases "^2.0.3" node-releases "^2.0.3"
picocolors "^1.0.0" picocolors "^1.0.0"
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@ -4471,6 +4490,14 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer@^5.2.1, buffer@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
bytes@3.0.0: bytes@3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@ -4650,6 +4677,11 @@ chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
chownr@^1.1.1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chrome-trace-event@^1.0.2: chrome-trace-event@^1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac"
@ -5016,7 +5048,7 @@ cross-env@^7.0.3:
dependencies: dependencies:
cross-spawn "^7.0.1" cross-spawn "^7.0.1"
cross-fetch@^3.1.5: cross-fetch@3.1.5, cross-fetch@^3.1.5:
version "3.1.5" version "3.1.5"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
@ -5232,11 +5264,6 @@ d3-format@^1.4.4:
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
d3-hierarchy@^1.1.8:
version "1.1.9"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
"d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1: "d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
@ -5343,7 +5370,7 @@ debug@3.1.0:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
version "4.3.4" version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -5499,6 +5526,11 @@ detective@^5.2.1:
defined "^1.0.0" defined "^1.0.0"
minimist "^1.2.6" minimist "^1.2.6"
devtools-protocol@0.0.1036444:
version "0.0.1036444"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1036444.tgz#a570d3cdde61527c82f9b03919847b8ac7b1c2b9"
integrity sha512-0y4f/T8H9lsESV9kKP1HDUXgHxCdniFeJh6Erq+FbdOEvp/Ydp9t8kcAAM5gOd17pMrTDlFWntoHtzzeTUWKNw==
dicer@^0.3.0: dicer@^0.3.0:
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.1.tgz#abf28921e3475bc5e801e74e0159fd94f927ba97" resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.1.tgz#abf28921e3475bc5e801e74e0159fd94f927ba97"
@ -6239,6 +6271,17 @@ extend@^3.0.0, extend@^3.0.2, extend@~3.0.2:
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
extract-zip@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
dependencies:
debug "^4.1.1"
get-stream "^5.1.0"
yauzl "^2.10.0"
optionalDependencies:
"@types/yauzl" "^2.9.1"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@ -6321,6 +6364,13 @@ fbjs@^3.0.0, fbjs@^3.0.1:
setimmediate "^1.0.5" setimmediate "^1.0.5"
ua-parser-js "^0.7.30" ua-parser-js "^0.7.30"
fd-slicer@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
dependencies:
pend "~1.2.0"
feed@^4.2.2: feed@^4.2.2:
version "4.2.2" version "4.2.2"
resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e"
@ -6580,6 +6630,11 @@ fresh@0.5.2:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@^10.0.1: fs-extra@^10.0.1:
version "10.1.0" version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
@ -7298,6 +7353,14 @@ http-proxy@^1.18.1:
follow-redirects "^1.0.0" follow-redirects "^1.0.0"
requires-port "^1.0.0" requires-port "^1.0.0"
https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
https-proxy-agent@^3.0.0: https-proxy-agent@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
@ -7306,14 +7369,6 @@ https-proxy-agent@^3.0.0:
agent-base "^4.3.0" agent-base "^4.3.0"
debug "^3.1.0" debug "^3.1.0"
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
human-signals@^2.1.0: human-signals@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@ -7336,6 +7391,11 @@ idb@7.0.1:
resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7" resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7"
integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg== integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg==
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore-by-default@^1.0.1: ignore-by-default@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
@ -7409,7 +7469,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -8564,6 +8624,11 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mkdirp-classic@^0.5.2:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@0.3.0: mkdirp@0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e"
@ -9207,15 +9272,10 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
performance-now@^0.2.0: pend@~1.2.0:
version "0.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU= integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
picocolors@^1.0.0: picocolors@^1.0.0:
version "1.0.0" version "1.0.0"
@ -9639,6 +9699,11 @@ process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
progress@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
promise@^7.1.1: promise@^7.1.1:
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
@ -9661,7 +9726,7 @@ prompts@^2.4.2:
kleur "^3.0.3" kleur "^3.0.3"
sisteransi "^1.0.5" sisteransi "^1.0.5"
prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -9829,7 +9894,7 @@ proxy-agent@^3.0.3:
proxy-from-env "^1.0.0" proxy-from-env "^1.0.0"
socks-proxy-agent "^4.0.1" socks-proxy-agent "^4.0.1"
proxy-from-env@^1.0.0: proxy-from-env@1.1.0, proxy-from-env@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
@ -9878,6 +9943,23 @@ pupa@^2.1.1:
dependencies: dependencies:
escape-goat "^2.0.0" escape-goat "^2.0.0"
puppeteer@18.0.5:
version "18.0.5"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-18.0.5.tgz#873223b17b92345182c5b5e8cfbd6f3117f1547d"
integrity sha512-s4erjxU0VtKojPvF+KvLKG6OHUPw7gO2YV1dtOsoryyCbhrs444fXb4QZqGWuTv3V/rgSCUzeixxu34g0ZkSMA==
dependencies:
cross-fetch "3.1.5"
debug "4.3.4"
devtools-protocol "0.0.1036444"
extract-zip "2.0.1"
https-proxy-agent "5.0.1"
progress "2.0.3"
proxy-from-env "1.1.0"
rimraf "3.0.2"
tar-fs "2.1.1"
unbzip2-stream "1.4.3"
ws "8.8.1"
pure-color@^1.2.0: pure-color@^1.2.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e"
@ -9922,13 +10004,6 @@ raf-schd@^4.0.2:
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
raf@^3.1.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
dependencies:
performance-now "^2.1.0"
randombytes@^2.1.0: randombytes@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@ -10128,15 +10203,6 @@ react-masonry-css@1.0.16:
resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c"
integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==
react-motion@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"
integrity sha512-9q3YAvHoUiWlP3cK0v+w1N5Z23HXMj4IF4YuvjvWegWqNPfLXsOBE/V7UvQGpXxHFKRQQcNcVQE31g9SB/6qgQ==
dependencies:
performance-now "^0.2.0"
prop-types "^15.5.8"
raf "^3.1.0"
react-query@3.39.0: react-query@3.39.0:
version "3.39.0" version "3.39.0"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.0.tgz#0caca7b0da98e65008bbcd4df0d25618c2100050" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.0.tgz#0caca7b0da98e65008bbcd4df0d25618c2100050"
@ -10274,7 +10340,7 @@ readable-stream@2, readable-stream@^2.0.1, readable-stream@~2.3.6:
string_decoder "~1.1.1" string_decoder "~1.1.1"
util-deprecate "~1.0.1" util-deprecate "~1.0.1"
readable-stream@^3.0.6, readable-stream@^3.1.1: readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.0" version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@ -10538,11 +10604,6 @@ requires-port@^1.0.0:
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-from@^4.0.0: resolve-from@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@ -11367,6 +11428,27 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
tar-fs@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
pump "^3.0.0"
tar-stream "^2.1.4"
tar-stream@^2.1.4:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
dependencies:
bl "^4.0.3"
end-of-stream "^1.4.1"
fs-constants "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.1.1"
teeny-request@^7.1.3: teeny-request@^7.1.3:
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633"
@ -11404,6 +11486,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
through@^2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
thunkify@^2.1.2: thunkify@^2.1.2:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d" resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
@ -11627,6 +11714,14 @@ unbox-primitive@^1.0.2:
has-symbols "^1.0.3" has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2" which-boxed-primitive "^1.0.2"
unbzip2-stream@1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
dependencies:
buffer "^5.2.1"
through "^2.3.8"
undefsafe@^2.0.5: undefsafe@^2.0.5:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
@ -12234,6 +12329,11 @@ write-file-atomic@^3.0.0:
signal-exit "^3.0.2" signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5" typedarray-to-buffer "^3.1.5"
ws@8.8.1:
version "8.8.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0"
integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==
ws@>=7.4.6: ws@>=7.4.6:
version "8.6.0" version "8.6.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.6.0.tgz#e5e9f1d9e7ff88083d0c0dd8281ea662a42c9c23" resolved "https://registry.yarnpkg.com/ws/-/ws-8.6.0.tgz#e5e9f1d9e7ff88083d0c0dd8281ea662a42c9c23"
@ -12314,6 +12414,14 @@ yargs@^16.2.0:
y18n "^5.0.5" y18n "^5.0.5"
yargs-parser "^20.2.2" yargs-parser "^20.2.2"
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
dependencies:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
yn@3.1.1: yn@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"