Merge branch 'main' into inga/mobilebetting
This commit is contained in:
commit
eede48f31d
|
@ -21,6 +21,25 @@ const computeInvestmentValue = (
|
|||
})
|
||||
}
|
||||
|
||||
export const computeInvestmentValueCustomProb = (
|
||||
bets: Bet[],
|
||||
contract: Contract,
|
||||
p: number
|
||||
) => {
|
||||
return sumBy(bets, (bet) => {
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
const { outcome, shares } = bet
|
||||
|
||||
const betP = outcome === 'YES' ? p : 1 - p
|
||||
|
||||
const payout = betP * shares
|
||||
const value = payout - (bet.loanAmount ?? 0)
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
|
|
|
@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
uniqueBettorIds?: string[]
|
||||
uniqueBettorCount?: number
|
||||
popularityScore?: number
|
||||
dailyScore?: number
|
||||
followerCount?: number
|
||||
featuredOnHomeRank?: number
|
||||
likedByUserIds?: string[]
|
||||
|
|
|
@ -10,6 +10,7 @@ export type Group = {
|
|||
totalContracts: number
|
||||
totalMembers: number
|
||||
aboutPostId?: string
|
||||
postIds: string[]
|
||||
chatDisabled?: boolean
|
||||
mostRecentContractAddedTime?: number
|
||||
cachedLeaderboard?: {
|
||||
|
|
|
@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
|
|||
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
|
||||
const profit = winnings - amount
|
||||
|
||||
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
|
||||
const payout = amount + (1 - DPM_FEES) * profit
|
||||
return { userId, profit, payout }
|
||||
})
|
||||
|
||||
|
|
|
@ -57,6 +57,7 @@ export type PrivateUser = {
|
|||
|
||||
email?: string
|
||||
weeklyTrendingEmailSent?: boolean
|
||||
weeklyPortfolioUpdateEmailSent?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
export const MINUTE_MS = 60 * 1000
|
||||
export const HOUR_MS = 60 * MINUTE_MS
|
||||
export const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
|
|
@ -582,6 +582,13 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
|||
"initialProb":25}'
|
||||
```
|
||||
|
||||
|
||||
### `POST /v0/market/[marketId]/close`
|
||||
|
||||
Closes a market on behalf of the authorized user.
|
||||
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
|
||||
|
||||
|
||||
### `POST /v0/market/[marketId]/resolve`
|
||||
|
||||
Resolves a market on behalf of the authorized user.
|
||||
|
|
|
@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
## Sites using Manifold
|
||||
|
||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
|
||||
|
||||
## API / Dev
|
||||
|
||||
|
@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
|
||||
|
||||
## Writeups
|
||||
|
||||
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
|
||||
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
|
||||
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
|
||||
|
@ -38,3 +38,10 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) 
|
||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
|
||||
|
||||
## Alumni
|
||||
|
||||
_These projects are no longer active, but were really really cool!_
|
||||
|
||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||
|
|
|
@ -4,11 +4,7 @@
|
|||
|
||||
### Do I have to pay real money in order to participate?
|
||||
|
||||
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||
|
||||
### What is the name for the currency Manifold uses, represented by M$?
|
||||
|
||||
Manifold Dollars, or mana for short.
|
||||
Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||
|
||||
### Can M$ be sold for real money?
|
||||
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
"mailgun-js": "0.22.0",
|
||||
"module-alias": "2.2.2",
|
||||
"node-fetch": "2",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"stripe": "8.194.0",
|
||||
"zod": "3.17.2"
|
||||
},
|
||||
|
@ -48,7 +47,8 @@
|
|||
"@types/mailgun-js": "0.22.12",
|
||||
"@types/module-alias": "2.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"firebase-functions-test": "0.3.3"
|
||||
"firebase-functions-test": "0.3.3",
|
||||
"puppeteer": "18.0.5"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
58
functions/src/close-market.ts
Normal file
58
functions/src/close-market.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getUser } from './utils'
|
||||
|
||||
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
closeTime: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
|
||||
export const closemarket = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId, closeTime } = validate(bodySchema, req.body)
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId } = contract
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
if (
|
||||
creatorId !== auth.uid &&
|
||||
!isManifoldId(auth.uid) &&
|
||||
!isAdmin(firebaseUser.email)
|
||||
)
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
const now = Date.now()
|
||||
if (!closeTime && contract.closeTime && contract.closeTime < now)
|
||||
throw new APIError(400, 'Contract already closed')
|
||||
|
||||
if (closeTime && closeTime < now)
|
||||
throw new APIError(
|
||||
400,
|
||||
'Close time must be in the future. ' +
|
||||
'Alternatively, do not provide a close time to close immediately.'
|
||||
)
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(500, 'Creator not found')
|
||||
|
||||
const updatedContract = {
|
||||
...contract,
|
||||
closeTime: closeTime ? closeTime : now,
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
||||
console.log('contract ', contractId, 'closed')
|
||||
|
||||
return updatedContract
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
|
|||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||
import { getValues } from './utils'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string().max(MAX_ANSWER_LENGTH),
|
||||
|
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
|||
return answer
|
||||
})
|
||||
|
||||
await addUserToContractFollowers(contractId, auth.uid)
|
||||
|
||||
return answer
|
||||
})
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
|||
anyoneCanJoin,
|
||||
totalContracts: 0,
|
||||
totalMembers: memberIds.length,
|
||||
postIds: [],
|
||||
}
|
||||
|
||||
await groupRef.create(group)
|
||||
|
|
|
@ -34,11 +34,12 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
|||
const postSchema = z.object({
|
||||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||
content: contentSchema,
|
||||
groupId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createpost = newEndpoint({}, async (req, auth) => {
|
||||
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)
|
||||
if (!creator)
|
||||
|
@ -60,6 +61,18 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
|
|
|
@ -0,0 +1,411 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top; margin-bottom: 30px" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
|
@ -0,0 +1,510 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
And here's some of the biggest changes in your portfolio:
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<tr>
|
||||
<td
|
||||
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
|
||||
<table role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question1Url}}'>
|
||||
{{question1Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question1Prob}}
|
||||
<!-- 9.9%-->
|
||||
<p class='change' style='{{question1ChangeStyle}}'>
|
||||
{{question1Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question2Url}}'>
|
||||
{{question2Title}}
|
||||
<!-- Will the US economy recover from the pandemic? blah blah blah-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question2Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question2ChangeStyle}}'>
|
||||
{{question2Change}}
|
||||
<!-- +7%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question3Url}}'>
|
||||
{{question3Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question3Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question3ChangeStyle}}'>
|
||||
{{question3Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question4Url}}'>
|
||||
{{question4Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question4Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question4ChangeStyle}}'>
|
||||
{{question4Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
|
@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
|||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getUser } from './utils'
|
||||
import { contractUrl, getUser } from './utils'
|
||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||
import { notification_reason_types } from '../../common/notification'
|
||||
import { Dictionary } from 'lodash'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import {
|
||||
getNotificationDestinationsForUser,
|
||||
notification_preference,
|
||||
} from '../../common/user-notification-preferences'
|
||||
PerContractInvestmentsData,
|
||||
OverallPerformanceData,
|
||||
} from 'functions/src/weekly-portfolio-emails'
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
reason: notification_reason_types,
|
||||
|
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
|
|||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
|
|||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold Markets one week anniversary gift',
|
||||
|
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
|
|||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Create your own prediction market',
|
||||
|
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
|
|||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'thank_you_for_purchases' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'thank_you_for_purchases'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
|
|||
)
|
||||
return
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'trending_markets' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'trending_markets'
|
||||
)
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
function imageSourceUrl(contract: Contract) {
|
||||
return buildCardUrl(getOpenGraphProps(contract))
|
||||
}
|
||||
|
@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async (
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendWeeklyPortfolioUpdateEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser,
|
||||
investments: PerContractInvestmentsData[],
|
||||
overallPerformance: OverallPerformanceData
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
||||
)
|
||||
return
|
||||
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'profit_loss_updates'
|
||||
)
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const templateData: Record<string, string> = {
|
||||
name: firstName,
|
||||
unsubscribeUrl,
|
||||
...overallPerformance,
|
||||
}
|
||||
investments.forEach((investment, i) => {
|
||||
templateData[`question${i + 1}Title`] = investment.questionTitle
|
||||
templateData[`question${i + 1}Url`] = investment.questionUrl
|
||||
templateData[`question${i + 1}Prob`] = investment.questionProb
|
||||
templateData[`question${i + 1}Change`] = 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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -27,9 +27,10 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './reset-weekly-emails-flags'
|
||||
export * from './on-update-contract-follow'
|
||||
export * from './on-update-like'
|
||||
export * from './weekly-portfolio-emails'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -50,6 +51,7 @@ export * from './resolve-market'
|
|||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
export * from './mana-bonus-email'
|
||||
export * from './close-market'
|
||||
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
|
@ -66,6 +68,7 @@ import { addliquidity } from './add-liquidity'
|
|||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { closemarket } from './close-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
|
@ -91,6 +94,7 @@ const addLiquidityFunction = toCloudFunction(addliquidity)
|
|||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||
const createGroupFunction = toCloudFunction(creategroup)
|
||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||
const closeMarketFunction = toCloudFunction(closemarket)
|
||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||
|
@ -115,11 +119,12 @@ export {
|
|||
withdrawLiquidityFunction as withdrawliquidity,
|
||||
createGroupFunction as creategroup,
|
||||
resolveMarketFunction as resolvemarket,
|
||||
closeMarketFunction as closemarket,
|
||||
unsubscribeFunction as unsubscribe,
|
||||
stripeWebhookFunction as stripewebhook,
|
||||
createCheckoutSessionFunction as createcheckoutsession,
|
||||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
createPostFunction as createpost,
|
||||
saveTwitchCredentials as savetwitchcredentials
|
||||
saveTwitchCredentials as savetwitchcredentials,
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
|
|||
'contract',
|
||||
'closed',
|
||||
user,
|
||||
'closed' + contract.id.slice(6, contract.id.length),
|
||||
contract.id + '-closed-at-' + contract.closeTime,
|
||||
contract.closeTime?.toString() ?? new Date().toString(),
|
||||
{ contract }
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { getAllPrivateUsers } from './utils'
|
||||
|
||||
export const resetWeeklyEmailsFlag = functions
|
||||
export const resetWeeklyEmailsFlags = functions
|
||||
.runWith({
|
||||
timeoutSeconds: 300,
|
||||
memory: '4GB',
|
||||
|
@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
|
|||
privateUsers.map(async (user) => {
|
||||
return firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: false,
|
||||
weeklyPortfolioUpdateEmailSent: false,
|
||||
})
|
||||
})
|
||||
)
|
|
@ -1,12 +1,15 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Bet } from 'common/bet'
|
||||
import { uniq } from 'lodash'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { log } from './utils'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||
|
||||
export const scoreContracts = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
export const scoreContracts = functions
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
await scoreContractsInternal()
|
||||
})
|
||||
|
@ -14,11 +17,12 @@ const firestore = admin.firestore()
|
|||
|
||||
async function scoreContractsInternal() {
|
||||
const now = Date.now()
|
||||
const lastHour = now - 60 * 60 * 1000
|
||||
const last3Days = now - 1000 * 60 * 60 * 24 * 3
|
||||
const hourAgo = now - HOUR_MS
|
||||
const dayAgo = now - DAY_MS
|
||||
const threeDaysAgo = now - DAY_MS * 3
|
||||
const activeContractsSnap = await firestore
|
||||
.collection('contracts')
|
||||
.where('lastUpdatedTime', '>', lastHour)
|
||||
.where('lastUpdatedTime', '>', hourAgo)
|
||||
.get()
|
||||
const activeContracts = activeContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
|
@ -39,16 +43,33 @@ async function scoreContractsInternal() {
|
|||
for (const contract of contracts) {
|
||||
const bets = await firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('createdTime', '>', last3Days)
|
||||
.where('createdTime', '>', threeDaysAgo)
|
||||
.get()
|
||||
const bettors = bets.docs
|
||||
.map((doc) => doc.data() as Bet)
|
||||
.map((bet) => bet.userId)
|
||||
const score = uniq(bettors).length
|
||||
if (contract.popularityScore !== score)
|
||||
const popularityScore = uniq(bettors).length
|
||||
|
||||
const wasCreatedToday = contract.createdTime > dayAgo
|
||||
|
||||
let dailyScore: number | undefined
|
||||
if (
|
||||
contract.outcomeType === 'BINARY' &&
|
||||
contract.mechanism === 'cpmm-1' &&
|
||||
!wasCreatedToday
|
||||
) {
|
||||
const percentChange = Math.abs(contract.probChanges.day)
|
||||
dailyScore = popularityScore * percentChange
|
||||
}
|
||||
|
||||
if (
|
||||
contract.popularityScore !== popularityScore ||
|
||||
contract.dailyScore !== dailyScore
|
||||
) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
.update({ popularityScore: score })
|
||||
.update(removeUndefinedProps({ popularityScore, dailyScore }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
115
functions/src/scripts/contest/create-markets.ts
Normal file
115
functions/src/scripts/contest/create-markets.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
|
||||
|
||||
import { data } from './criticism-and-red-teaming'
|
||||
|
||||
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||
// DEV API key for Criticism and Red Teaming (@CARTBot)
|
||||
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
|
||||
|
||||
type CEPSubmission = {
|
||||
title: string
|
||||
author?: string
|
||||
link: string
|
||||
}
|
||||
|
||||
// Use the API to create a new market for this Cause Exploration Prize submission
|
||||
async function postMarket(submission: CEPSubmission) {
|
||||
const { title, author } = submission
|
||||
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Key ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcomeType: 'BINARY',
|
||||
question: `"${title}" by ${author ?? 'anonymous'}`,
|
||||
description: makeDescription(submission),
|
||||
closeTime: Date.parse('2022-09-30').valueOf(),
|
||||
initialProb: 10,
|
||||
// Super secret options:
|
||||
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
|
||||
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
|
||||
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
|
||||
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
|
||||
visibility: 'unlisted',
|
||||
// TODO: Increase liquidity?
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
console.log('Created market:', data.slug)
|
||||
}
|
||||
|
||||
async function postAll() {
|
||||
for (const submission of data.slice(0, 3)) {
|
||||
await postMarket(submission)
|
||||
}
|
||||
}
|
||||
postAll()
|
||||
|
||||
/* Example curl request:
|
||||
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}'
|
||||
--data-raw '{"outcomeType":"BINARY", \
|
||||
"question":"Is there life on Mars?", \
|
||||
"description":"I'm not going to type some long ass example description.", \
|
||||
"closeTime":1700000000000, \
|
||||
"initialProb":25}'
|
||||
*/
|
||||
|
||||
function makeDescription(submission: CEPSubmission) {
|
||||
const { title, author, link } = submission
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
|
||||
{
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
href: link,
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
type: 'text',
|
||||
text: title,
|
||||
},
|
||||
{ text: '" win any prize in the ', type: 'text' },
|
||||
{
|
||||
text: 'EA Criticism and Red Teaming Contest',
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: '?', type: 'text' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
{ type: 'paragraph' },
|
||||
{
|
||||
type: 'iframe',
|
||||
attrs: {
|
||||
allowfullscreen: true,
|
||||
src: link,
|
||||
frameborder: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'doc',
|
||||
}
|
||||
}
|
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
File diff suppressed because it is too large
Load Diff
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
|
||||
import * as fs from 'fs'
|
||||
import * as puppeteer from 'puppeteer'
|
||||
|
||||
export function scrapeEA(contestLink: string, fileName: string) {
|
||||
;(async () => {
|
||||
const browser = await puppeteer.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
await page.goto(contestLink)
|
||||
|
||||
let loadMoreButton = await page.$('.LoadMore-root')
|
||||
|
||||
while (loadMoreButton) {
|
||||
await loadMoreButton.click()
|
||||
await page.waitForNetworkIdle()
|
||||
loadMoreButton = await page.$('.LoadMore-root')
|
||||
}
|
||||
|
||||
/* Run javascript inside the page */
|
||||
const data = await page.evaluate(() => {
|
||||
const list = []
|
||||
const items = document.querySelectorAll('.PostsItem2-root')
|
||||
|
||||
for (const item of items) {
|
||||
const link =
|
||||
'https://forum.effectivealtruism.org' +
|
||||
item?.querySelector('a')?.getAttribute('href')
|
||||
|
||||
// Replace '&' with '&'
|
||||
const clean = (str: string | undefined) => str?.replace(/&/g, '&')
|
||||
|
||||
list.push({
|
||||
title: clean(item?.querySelector('a>span>span')?.innerHTML),
|
||||
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
|
||||
link: link,
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
`./src/scripts/contest/${fileName}.ts`,
|
||||
`export const data = ${JSON.stringify(data, null, 2)}`
|
||||
)
|
||||
|
||||
console.log(data)
|
||||
await browser.close()
|
||||
})()
|
||||
}
|
||||
|
||||
scrapeEA(
|
||||
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
|
||||
'criticism-and-red-teaming'
|
||||
)
|
|
@ -41,6 +41,7 @@ const createGroup = async (
|
|||
anyoneCanJoin: true,
|
||||
totalContracts: contracts.length,
|
||||
totalMembers: 1,
|
||||
postIds: [],
|
||||
}
|
||||
await groupRef.create(group)
|
||||
// create a GroupMemberDoc for the creator
|
||||
|
|
|
@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { testscheduledfunction } from './test-scheduled-function'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
|||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
||||
|
|
17
functions/src/test-scheduled-function.ts
Normal file
17
functions/src/test-scheduled-function.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
|
||||
import { isProd } from './utils'
|
||||
|
||||
// Function for testing scheduled functions locally
|
||||
export const testscheduledfunction = newEndpoint(
|
||||
{ method: 'GET', memory: '4GiB' },
|
||||
async (_req) => {
|
||||
if (isProd())
|
||||
throw new APIError(400, 'This function is only available in dev mode')
|
||||
|
||||
// Replace your function here
|
||||
await sendPortfolioUpdateEmailsToAllUsers()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
)
|
|
@ -170,3 +170,7 @@ export const chargeUser = (
|
|||
export const getContractPath = (contract: Contract) => {
|
||||
return `/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
export function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
|
|
@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
? await getAllPrivateUsers()
|
||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return (
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
const privateUsersToSendEmailsTo = privateUsers
|
||||
.filter((user) => {
|
||||
return (
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
.slice(150) // Send the emails out in batches
|
||||
log(
|
||||
'Sending weekly trending emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
|
@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
trendingContracts.map((c) => c.question).join('\n ')
|
||||
)
|
||||
|
||||
// TODO: convert to Promise.all
|
||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
|
@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
})
|
||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
||||
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// choose random subset of contracts to send to user
|
||||
|
|
280
functions/src/weekly-portfolio-emails.ts
Normal file
280
functions/src/weekly-portfolio-emails.ts
Normal 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
|
||||
}
|
|
@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
|
||||
<div className="modal">
|
||||
<div className="modal-box">
|
||||
<div className="mb-6 text-xl">Get Manifold Dollars</div>
|
||||
<div className="mb-6 text-xl">Get Mana</div>
|
||||
|
||||
<div className="mb-6 text-gray-500">
|
||||
Use Manifold Dollars to trade in your favorite markets. <br /> (Not
|
||||
Buy mana (M$) to trade in your favorite markets. <br /> (Not
|
||||
redeemable for cash.)
|
||||
</div>
|
||||
|
||||
|
|
|
@ -38,6 +38,8 @@ export function AmountInput(props: {
|
|||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
const isMobile = useIsMobile(768)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col className={className}>
|
||||
|
|
|
@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie'
|
|||
|
||||
// Either we haven't looked up the logged in user yet (undefined), or we know
|
||||
// the user is not logged in (null), or we know the user is logged in.
|
||||
type AuthUser = undefined | null | UserAndPrivateUser
|
||||
export type AuthUser = undefined | null | UserAndPrivateUser
|
||||
|
||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||
|
|
|
@ -40,7 +40,7 @@ export function Avatar(props: {
|
|||
style={{ maxWidth: `${s * 0.25}rem` }}
|
||||
src={avatarUrl}
|
||||
onClick={onClick}
|
||||
alt={username}
|
||||
alt={`${username ?? 'Unknown user'} avatar`}
|
||||
onError={() => {
|
||||
// If the image doesn't load, clear the avatarUrl to show the default
|
||||
// Mostly for localhost, when getting a 403 from googleusercontent
|
||||
|
|
|
@ -77,7 +77,7 @@ export function BetsList(props: { user: User }) {
|
|||
}, [contractList])
|
||||
|
||||
const [sort, setSort] = useState<BetSort>('newest')
|
||||
const [filter, setFilter] = useState<BetFilter>('open')
|
||||
const [filter, setFilter] = useState<BetFilter>('all')
|
||||
const [page, setPage] = useState(0)
|
||||
const start = page * CONTRACTS_PER_PAGE
|
||||
const end = start + CONTRACTS_PER_PAGE
|
||||
|
@ -155,34 +155,25 @@ export function BetsList(props: { user: User }) {
|
|||
(c) => contractsMetrics[c.id].netPayout
|
||||
)
|
||||
|
||||
const totalPnl = user.profitCached.allTime
|
||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||
const investedProfitPercent =
|
||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
|
||||
<Row className="gap-8">
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Investment value</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(currentNetInvestment)}{' '}
|
||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Total profit</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(totalPnl)}{' '}
|
||||
<ProfitBadge profitPercent={totalProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="justify-between gap-4 sm:flex-row">
|
||||
<Col>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||
Investment value
|
||||
</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(currentNetInvestment)}{' '}
|
||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Row className="gap-8">
|
||||
<Row className="gap-2">
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as BetFilter)}
|
||||
>
|
||||
|
@ -195,7 +186,7 @@ export function BetsList(props: { user: User }) {
|
|||
</select>
|
||||
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as BetSort)}
|
||||
>
|
||||
|
@ -205,7 +196,7 @@ export function BetsList(props: { user: User }) {
|
|||
<option value="closeTime">Close date</option>
|
||||
</select>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Col className="mt-6 divide-y">
|
||||
{displayedContracts.length === 0 ? (
|
||||
|
@ -610,18 +601,24 @@ function BetRow(props: {
|
|||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const saleAmount = saleBet?.sale?.amount
|
||||
// calculateSaleAmount is very slow right now so that's why we memoized this
|
||||
const payout = useMemo(() => {
|
||||
const saleBetAmount = saleBet?.sale?.amount
|
||||
if (saleBetAmount) {
|
||||
return saleBetAmount
|
||||
} else if (contract.isResolved) {
|
||||
return resolvedPayout(contract, bet)
|
||||
} else {
|
||||
return calculateSaleAmount(contract, bet, unfilledBets)
|
||||
}
|
||||
}, [contract, bet, saleBet, unfilledBets])
|
||||
|
||||
const saleDisplay = isAnte ? (
|
||||
'ANTE'
|
||||
) : saleAmount !== undefined ? (
|
||||
<>{formatMoney(saleAmount)} (sold)</>
|
||||
) : saleBet ? (
|
||||
<>{formatMoney(payout)} (sold)</>
|
||||
) : (
|
||||
formatMoney(
|
||||
isResolved
|
||||
? resolvedPayout(contract, bet)
|
||||
: calculateSaleAmount(contract, bet, unfilledBets)
|
||||
)
|
||||
formatMoney(payout)
|
||||
)
|
||||
|
||||
const payoutIfChosenDisplay =
|
||||
|
|
|
@ -11,7 +11,7 @@ import { Row } from './layout/row'
|
|||
import { LoadingIndicator } from './loading-indicator'
|
||||
|
||||
export function CommentInput(props: {
|
||||
replyToUser?: { id: string; username: string }
|
||||
replyTo?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
|
@ -19,7 +19,7 @@ export function CommentInput(props: {
|
|||
onSubmitComment?: (editor: Editor) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { parentAnswerOutcome, parentCommentId, replyToUser, onSubmitComment } =
|
||||
const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
|
||||
props
|
||||
const user = useUser()
|
||||
|
||||
|
@ -55,7 +55,7 @@ export function CommentInput(props: {
|
|||
<CommentInputTextArea
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
replyToUser={replyToUser}
|
||||
replyTo={replyTo}
|
||||
user={user}
|
||||
submitComment={submitComment}
|
||||
isSubmitting={isSubmitting}
|
||||
|
@ -67,14 +67,13 @@ export function CommentInput(props: {
|
|||
|
||||
export function CommentInputTextArea(props: {
|
||||
user: User | undefined | null
|
||||
replyToUser?: { id: string; username: string }
|
||||
replyTo?: { id: string; username: string }
|
||||
editor: Editor | null
|
||||
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||
submitComment: () => void
|
||||
isSubmitting: boolean
|
||||
}) {
|
||||
const { user, editor, upload, submitComment, isSubmitting, replyToUser } =
|
||||
props
|
||||
const { user, editor, upload, submitComment, isSubmitting, replyTo } = props
|
||||
useEffect(() => {
|
||||
editor?.setEditable(!isSubmitting)
|
||||
}, [isSubmitting, editor])
|
||||
|
@ -108,12 +107,12 @@ export function CommentInputTextArea(props: {
|
|||
},
|
||||
})
|
||||
// insert at mention and focus
|
||||
if (replyToUser) {
|
||||
if (replyTo) {
|
||||
editor
|
||||
.chain()
|
||||
.setContent({
|
||||
type: 'mention',
|
||||
attrs: { label: replyToUser.username, id: replyToUser.id },
|
||||
attrs: { label: replyTo.username, id: replyTo.id },
|
||||
})
|
||||
.insertContent(' ')
|
||||
.focus()
|
||||
|
@ -127,7 +126,7 @@ export function CommentInputTextArea(props: {
|
|||
<TextEditor editor={editor} upload={upload}>
|
||||
{user && !isSubmitting && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
|
||||
className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300"
|
||||
disabled={!editor || editor.isEmpty}
|
||||
onClick={submit}
|
||||
>
|
||||
|
|
|
@ -9,7 +9,14 @@ import {
|
|||
} from './contract/contracts-grid'
|
||||
import { ShowTime } from './contract/contract-details'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import {
|
||||
|
@ -32,22 +39,26 @@ import {
|
|||
searchClient,
|
||||
searchIndexName,
|
||||
} from 'web/lib/service/algolia'
|
||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import { AdjustmentsIcon } from '@heroicons/react/solid'
|
||||
import { Button } from './button'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Title } from './title'
|
||||
|
||||
export const SORTS = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Trending', value: 'score' },
|
||||
{ label: `Most traded`, value: 'most-traded' },
|
||||
{ label: 'Daily trending', value: 'daily-score' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: '24h change', value: 'prob-change-day' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Subsidy', value: 'liquidity' },
|
||||
{ label: 'Close date', value: 'close-date' },
|
||||
{ label: 'Closing soon', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
{ label: 'Highest %', value: 'prob-descending' },
|
||||
{ label: 'Lowest %', value: 'prob-ascending' },
|
||||
] as const
|
||||
|
||||
export type Sort = typeof SORTS[number]['value']
|
||||
export const PROB_SORTS = ['prob-descending', 'prob-ascending']
|
||||
|
||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
||||
|
||||
|
@ -78,11 +89,13 @@ export function ContractSearch(props: {
|
|||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
noLinkAvatar?: boolean
|
||||
showProbChange?: boolean
|
||||
}
|
||||
headerClassName?: string
|
||||
persistPrefix?: string
|
||||
useQueryUrlParam?: boolean
|
||||
isWholePage?: boolean
|
||||
includeProbSorts?: boolean
|
||||
noControls?: boolean
|
||||
maxResults?: number
|
||||
renderContracts?: (
|
||||
|
@ -90,6 +103,7 @@ export function ContractSearch(props: {
|
|||
loadMore: () => void
|
||||
) => ReactNode
|
||||
autoFocus?: boolean
|
||||
profile?: boolean | undefined
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -104,11 +118,13 @@ export function ContractSearch(props: {
|
|||
headerClassName,
|
||||
persistPrefix,
|
||||
useQueryUrlParam,
|
||||
includeProbSorts,
|
||||
isWholePage,
|
||||
noControls,
|
||||
maxResults,
|
||||
renderContracts,
|
||||
autoFocus,
|
||||
profile,
|
||||
} = props
|
||||
|
||||
const [state, setState] = usePersistentState(
|
||||
|
@ -116,6 +132,7 @@ export function ContractSearch(props: {
|
|||
numPages: 1,
|
||||
pages: [] as Contract[][],
|
||||
showTime: null as ShowTime | null,
|
||||
showProbChange: false,
|
||||
},
|
||||
!persistPrefix
|
||||
? undefined
|
||||
|
@ -169,8 +186,9 @@ export function ContractSearch(props: {
|
|||
const newPage = results.hits as any as Contract[]
|
||||
const showTime =
|
||||
sort === 'close-date' || sort === 'resolve-date' ? sort : null
|
||||
const showProbChange = sort === 'daily-score'
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -188,6 +206,12 @@ export function ContractSearch(props: {
|
|||
}, 100)
|
||||
).current
|
||||
|
||||
const updatedCardUIOptions = useMemo(() => {
|
||||
if (cardUIOptions?.showProbChange === undefined && state.showProbChange)
|
||||
return { ...cardUIOptions, showProbChange: true }
|
||||
return cardUIOptions
|
||||
}, [cardUIOptions, state.showProbChange])
|
||||
|
||||
const contracts = state.pages
|
||||
.flat()
|
||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||
|
@ -209,6 +233,7 @@ export function ContractSearch(props: {
|
|||
persistPrefix={persistPrefix}
|
||||
hideOrderSelector={hideOrderSelector}
|
||||
useQueryUrlParam={useQueryUrlParam}
|
||||
includeProbSorts={includeProbSorts}
|
||||
user={user}
|
||||
onSearchParametersChanged={onSearchParametersChanged}
|
||||
noControls={noControls}
|
||||
|
@ -216,6 +241,10 @@ export function ContractSearch(props: {
|
|||
/>
|
||||
{renderContracts ? (
|
||||
renderContracts(renderedContracts, performQuery)
|
||||
) : renderedContracts && renderedContracts.length === 0 && profile ? (
|
||||
<p className="mx-2 text-gray-500">
|
||||
This creator does not yet have any markets.
|
||||
</p>
|
||||
) : (
|
||||
<ContractsGrid
|
||||
contracts={renderedContracts}
|
||||
|
@ -223,7 +252,7 @@ export function ContractSearch(props: {
|
|||
showTime={state.showTime ?? undefined}
|
||||
onContractClick={onContractClick}
|
||||
highlightOptions={highlightOptions}
|
||||
cardUIOptions={cardUIOptions}
|
||||
cardUIOptions={updatedCardUIOptions}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
@ -238,6 +267,7 @@ function ContractSearchControls(props: {
|
|||
additionalFilter?: AdditionalFilter
|
||||
persistPrefix?: string
|
||||
hideOrderSelector?: boolean
|
||||
includeProbSorts?: boolean
|
||||
onSearchParametersChanged: (params: SearchParameters) => void
|
||||
useQueryUrlParam?: boolean
|
||||
user?: User | null
|
||||
|
@ -257,6 +287,7 @@ function ContractSearchControls(props: {
|
|||
user,
|
||||
noControls,
|
||||
autoFocus,
|
||||
includeProbSorts,
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
|
@ -270,6 +301,8 @@ function ContractSearchControls(props: {
|
|||
}
|
||||
)
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const sortKey = `${persistPrefix}-search-sort`
|
||||
const savedSort = safeLocalStorage()?.getItem(sortKey)
|
||||
|
||||
|
@ -415,30 +448,33 @@ function ContractSearchControls(props: {
|
|||
className="input input-bordered w-full"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{!query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
{!isMobile && (
|
||||
<SearchFilters
|
||||
filter={filter}
|
||||
selectFilter={selectFilter}
|
||||
hideOrderSelector={hideOrderSelector}
|
||||
selectSort={selectSort}
|
||||
sort={sort}
|
||||
className={'flex flex-row gap-2'}
|
||||
includeProbSorts={includeProbSorts}
|
||||
/>
|
||||
)}
|
||||
{!hideOrderSelector && !query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{SORTS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileSearchBar
|
||||
children={
|
||||
<SearchFilters
|
||||
filter={filter}
|
||||
selectFilter={selectFilter}
|
||||
hideOrderSelector={hideOrderSelector}
|
||||
selectSort={selectSort}
|
||||
sort={sort}
|
||||
className={'flex flex-col gap-4'}
|
||||
includeProbSorts={includeProbSorts}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
|
@ -481,3 +517,78 @@ function ContractSearchControls(props: {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchFilters(props: {
|
||||
filter: string
|
||||
selectFilter: (newFilter: filter) => void
|
||||
hideOrderSelector: boolean | undefined
|
||||
selectSort: (newSort: Sort) => void
|
||||
sort: string
|
||||
className?: string
|
||||
includeProbSorts?: boolean
|
||||
}) {
|
||||
const {
|
||||
filter,
|
||||
selectFilter,
|
||||
hideOrderSelector,
|
||||
selectSort,
|
||||
sort,
|
||||
className,
|
||||
includeProbSorts,
|
||||
} = props
|
||||
|
||||
const sorts = includeProbSorts
|
||||
? SORTS
|
||||
: SORTS.filter((sort) => !PROB_SORTS.includes(sort.value))
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
{!hideOrderSelector && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{sorts.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileSearchBar(props: { children: ReactNode }) {
|
||||
const { children } = props
|
||||
const [openFilters, setOpenFilters] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button color="gray-white" onClick={() => setOpenFilters(true)}>
|
||||
<AdjustmentsIcon className="my-auto h-7" />
|
||||
</Button>
|
||||
<Modal
|
||||
open={openFilters}
|
||||
setOpen={setOpenFilters}
|
||||
position="top"
|
||||
className="rounded-lg bg-white px-4 pb-4"
|
||||
>
|
||||
<Col>
|
||||
<Title text="Filter Markets" />
|
||||
{children}
|
||||
</Col>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Col } from '../layout/col'
|
|||
import {
|
||||
BinaryContract,
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
|
@ -32,6 +33,8 @@ import { track } from '@amplitude/analytics-browser'
|
|||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { Tooltip } from '../tooltip'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { ProbChange } from './prob-change-table'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -379,3 +382,34 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContractCardProbChange(props: {
|
||||
contract: CPMMBinaryContract
|
||||
noLinkAvatar?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, noLinkAvatar, className } = props
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
className,
|
||||
'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
className={'px-6 pt-4'}
|
||||
noLink={noLinkAvatar}
|
||||
/>
|
||||
<Row className={clsx('items-start justify-between gap-4 ', className)}>
|
||||
<SiteLink
|
||||
className="pl-6 pr-0 pt-2 pb-4 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-3">{contract.question}</span>
|
||||
</SiteLink>
|
||||
<ProbChange className="py-2 pr-4" contract={contract} />
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { listUsers, User } from 'web/lib/firebase/users'
|
||||
import { memo } from 'react'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { FeedBet } from '../feed/feed-bets'
|
||||
import { FeedComment } from '../feed/feed-comments'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
|
@ -13,61 +12,48 @@ import { Leaderboard } from '../leaderboard'
|
|||
import { Title } from '../title'
|
||||
import { BETTORS } from 'common/user'
|
||||
|
||||
export function ContractLeaderboard(props: {
|
||||
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
}) {
|
||||
const { contract, bets } = props
|
||||
const [users, setUsers] = useState<User[]>()
|
||||
|
||||
const { userProfits, top5Ids } = useMemo(() => {
|
||||
// Create a map of userIds to total profits (including sales)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const betsByUser = groupBy(openBets, 'userId')
|
||||
|
||||
const userProfits = mapValues(betsByUser, (bets) =>
|
||||
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||
)
|
||||
// Find the 5 users with the most profits
|
||||
const top5Ids = Object.entries(userProfits)
|
||||
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
|
||||
.filter(([, p]) => p > 0)
|
||||
.slice(0, 5)
|
||||
.map(([id]) => id)
|
||||
return { userProfits, top5Ids }
|
||||
}, [contract, bets])
|
||||
|
||||
useEffect(() => {
|
||||
if (top5Ids.length > 0) {
|
||||
listUsers(top5Ids).then((users) => {
|
||||
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||
setUsers(sortedUsers)
|
||||
})
|
||||
// Create a map of userIds to total profits (including sales)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const betsByUser = groupBy(openBets, 'userId')
|
||||
const userProfits = mapValues(betsByUser, (bets) => {
|
||||
return {
|
||||
name: bets[0].userName,
|
||||
username: bets[0].userUsername,
|
||||
avatarUrl: bets[0].userAvatarUrl,
|
||||
total: sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount),
|
||||
}
|
||||
}, [userProfits, top5Ids])
|
||||
})
|
||||
// Find the 5 users with the most profits
|
||||
const top5 = Object.values(userProfits)
|
||||
.sort((p1, p2) => p2.total - p1.total)
|
||||
.filter((p) => p.total > 0)
|
||||
.slice(0, 5)
|
||||
|
||||
return users && users.length > 0 ? (
|
||||
return top5 && top5.length > 0 ? (
|
||||
<Leaderboard
|
||||
title={`🏅 Top ${BETTORS}`}
|
||||
users={users || []}
|
||||
entries={top5 || []}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
|
||||
renderCell: (entry) => formatMoney(entry.total),
|
||||
},
|
||||
]}
|
||||
className="mt-12 max-w-sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
})
|
||||
|
||||
export function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract, bets, comments } = props
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
// todo: this stuff should be calced in DB at resolve time
|
||||
const comments = useComments(contract.id)
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
|
@ -88,29 +74,23 @@ export function ContractTopTrades(props: {
|
|||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = betsById[topBetId]?.userName
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
// And also the comment with the highest profit
|
||||
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
|
||||
|
||||
return (
|
||||
<div className="mt-12 max-w-sm">
|
||||
{topCommentId && profitById[topCommentId] > 0 && (
|
||||
{topComment && profitById[topComment.id] > 0 && (
|
||||
<>
|
||||
<Title text="💬 Proven correct" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedComment
|
||||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
/>
|
||||
<FeedComment contract={contract} comment={topComment} />
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
{topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Best bet" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
|
|
|
@ -47,14 +47,14 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
times.push(latestTime.valueOf())
|
||||
probs.push(probs[probs.length - 1])
|
||||
|
||||
const quartiles = [0, 25, 50, 75, 100]
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100]
|
||||
|
||||
const yTickValues = isBinary
|
||||
? quartiles
|
||||
: quartiles.map((x) => x / 100).map(f)
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const startDate = dayjs(times[0])
|
||||
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
|
||||
|
@ -104,7 +104,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
return (
|
||||
<div
|
||||
className="w-full overflow-visible"
|
||||
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
|
||||
style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }}
|
||||
>
|
||||
<ResponsiveLine
|
||||
data={data}
|
||||
|
@ -144,7 +144,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
pointBorderWidth={1}
|
||||
pointBorderColor="#fff"
|
||||
enableSlices="x"
|
||||
enableGridX={!!width && width >= 800}
|
||||
enableGridX={false}
|
||||
enableArea
|
||||
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
|
||||
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
||||
|
|
|
@ -5,19 +5,19 @@ import { FeedBet } from '../feed/feed-bets'
|
|||
import { FeedLiquidity } from '../feed/feed-liquidity'
|
||||
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
|
||||
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { PAST_BETS, User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Tabs } from '../layout/tabs'
|
||||
import { Col } from '../layout/col'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { capitalize } from 'lodash'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -25,21 +25,13 @@ import {
|
|||
} from 'common/antes'
|
||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract, user, bets, comments } = props
|
||||
export function ContractTabs(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const user = useUser()
|
||||
const userBets =
|
||||
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||
const visibleBets = bets.filter(
|
||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
|
||||
const yourTrades = (
|
||||
<div>
|
||||
|
@ -57,19 +49,16 @@ export function ContractTabs(props: {
|
|||
|
||||
return (
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
currentPageForAnalytics={'contract'}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Comments',
|
||||
content: (
|
||||
<CommentsTabContent contract={contract} comments={comments} />
|
||||
),
|
||||
content: <CommentsTabContent contract={contract} />,
|
||||
},
|
||||
{
|
||||
title: capitalize(PAST_BETS),
|
||||
content: (
|
||||
<ContractBetsActivity contract={contract} bets={visibleBets} />
|
||||
),
|
||||
content: <BetsTabContent contract={contract} bets={bets} />,
|
||||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
|
@ -86,46 +75,87 @@ export function ContractTabs(props: {
|
|||
|
||||
const CommentsTabContent = memo(function CommentsTabContent(props: {
|
||||
contract: Contract
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract, comments } = props
|
||||
const { contract } = props
|
||||
const tips = useTipTxns({ contractId: contract.id })
|
||||
const updatedComments = useComments(contract.id) ?? comments
|
||||
const comments = useComments(contract.id)
|
||||
if (comments == null) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
const generalComments = comments.filter(
|
||||
(c) => c.answerOutcome === undefined && c.betId === undefined
|
||||
)
|
||||
const sortedAnswers = sortBy(
|
||||
contract.answers,
|
||||
(a) => -getOutcomeProbability(contract, a.id)
|
||||
)
|
||||
const commentsByOutcome = groupBy(
|
||||
comments,
|
||||
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<FreeResponseContractCommentsActivity
|
||||
contract={contract}
|
||||
comments={updatedComments}
|
||||
tips={tips}
|
||||
/>
|
||||
{sortedAnswers.map((answer) => (
|
||||
<div key={answer.id} className="relative pb-4">
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FeedAnswerCommentGroup
|
||||
contract={contract}
|
||||
answer={answer}
|
||||
answerComments={sortBy(
|
||||
commentsByOutcome[answer.number.toString()] ?? [],
|
||||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Col className="mt-8 flex w-full">
|
||||
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
|
||||
<div className="mb-4 w-full border-b border-gray-200" />
|
||||
<ContractCommentsActivity
|
||||
contract={contract}
|
||||
comments={updatedComments.filter(
|
||||
(comment) =>
|
||||
comment.answerOutcome === undefined &&
|
||||
comment.betId === undefined
|
||||
)}
|
||||
tips={tips}
|
||||
/>
|
||||
<ContractCommentInput className="mb-5" contract={contract} />
|
||||
{generalComments.map((comment) => (
|
||||
<FeedCommentThread
|
||||
key={comment.id}
|
||||
contract={contract}
|
||||
parentComment={comment}
|
||||
threadComments={[]}
|
||||
tips={tips}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_')
|
||||
const topLevelComments = commentsByParent['_'] ?? []
|
||||
return (
|
||||
<ContractCommentsActivity
|
||||
contract={contract}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
/>
|
||||
<>
|
||||
<ContractCommentInput className="mb-5" contract={contract} />
|
||||
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
|
||||
<FeedCommentThread
|
||||
key={parent.id}
|
||||
contract={contract}
|
||||
parentComment={parent}
|
||||
threadComments={sortBy(
|
||||
commentsByParent[parent.id] ?? [],
|
||||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
|
||||
const BetsTabContent = memo(function BetsTabContent(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
}) {
|
||||
const { contract, bets } = props
|
||||
const [page, setPage] = useState(0)
|
||||
const ITEMS_PER_PAGE = 50
|
||||
|
@ -133,6 +163,9 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
|
|||
const end = start + ITEMS_PER_PAGE
|
||||
|
||||
const lps = useLiquidity(contract.id) ?? []
|
||||
const visibleBets = bets.filter(
|
||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
const visibleLps = lps.filter(
|
||||
(l) =>
|
||||
!l.isAnte &&
|
||||
|
@ -142,7 +175,7 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
|
|||
)
|
||||
|
||||
const items = [
|
||||
...bets.map((bet) => ({
|
||||
...visibleBets.map((bet) => ({
|
||||
type: 'bet' as const,
|
||||
id: bet.id + '-' + bet.isSold,
|
||||
bet,
|
||||
|
@ -184,74 +217,4 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
|
|||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ContractCommentsActivity(props: {
|
||||
contract: Contract
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, comments, tips } = props
|
||||
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
|
||||
const topLevelComments = sortBy(
|
||||
commentsByParentId['_'] ?? [],
|
||||
(c) => -c.createdTime
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContractCommentInput className="mb-5" contract={contract} />
|
||||
{topLevelComments.map((parent) => (
|
||||
<FeedCommentThread
|
||||
key={parent.id}
|
||||
contract={contract}
|
||||
parentComment={parent}
|
||||
threadComments={sortBy(
|
||||
commentsByParentId[parent.id] ?? [],
|
||||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FreeResponseContractCommentsActivity(props: {
|
||||
contract: FreeResponseContract
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, comments, tips } = props
|
||||
|
||||
const sortedAnswers = sortBy(
|
||||
contract.answers,
|
||||
(answer) => -getOutcomeProbability(contract, answer.number.toString())
|
||||
)
|
||||
const commentsByOutcome = groupBy(
|
||||
comments,
|
||||
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedAnswers.map((answer) => (
|
||||
<div key={answer.id} className="relative pb-4">
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FeedAnswerCommentGroup
|
||||
contract={contract}
|
||||
answer={answer}
|
||||
answerComments={sortBy(
|
||||
commentsByOutcome[answer.number.toString()] ?? [],
|
||||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Contract } from 'web/lib/firebase/contracts'
|
|||
import { User } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { ContractCard } from './contract-card'
|
||||
import { ContractCard, ContractCardProbChange } from './contract-card'
|
||||
import { ShowTime } from './contract-details'
|
||||
import { ContractSearch } from '../contract-search'
|
||||
import { useCallback } from 'react'
|
||||
|
@ -10,6 +10,7 @@ import clsx from 'clsx'
|
|||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { VisibilityObserver } from '../visibility-observer'
|
||||
import Masonry from 'react-masonry-css'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
|
@ -25,6 +26,7 @@ export function ContractsGrid(props: {
|
|||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
noLinkAvatar?: boolean
|
||||
showProbChange?: boolean
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
trackingPostfix?: string
|
||||
|
@ -39,7 +41,8 @@ export function ContractsGrid(props: {
|
|||
highlightOptions,
|
||||
trackingPostfix,
|
||||
} = props
|
||||
const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
|
||||
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
|
||||
cardUIOptions || {}
|
||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||
const onVisibilityUpdated = useCallback(
|
||||
(visible) => {
|
||||
|
@ -73,24 +76,31 @@ export function ContractsGrid(props: {
|
|||
className="-ml-4 flex w-auto"
|
||||
columnClassName="pl-4 bg-clip-padding"
|
||||
>
|
||||
{contracts.map((contract) => (
|
||||
<ContractCard
|
||||
contract={contract}
|
||||
key={contract.id}
|
||||
showTime={showTime}
|
||||
onClick={
|
||||
onContractClick ? () => onContractClick(contract) : undefined
|
||||
}
|
||||
noLinkAvatar={noLinkAvatar}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
trackingPostfix={trackingPostfix}
|
||||
className={clsx(
|
||||
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{contracts.map((contract) =>
|
||||
showProbChange && contract.mechanism === 'cpmm-1' ? (
|
||||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract as CPMMBinaryContract}
|
||||
/>
|
||||
) : (
|
||||
<ContractCard
|
||||
contract={contract}
|
||||
key={contract.id}
|
||||
showTime={showTime}
|
||||
onClick={
|
||||
onContractClick ? () => onContractClick(contract) : undefined
|
||||
}
|
||||
noLinkAvatar={noLinkAvatar}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
trackingPostfix={trackingPostfix}
|
||||
className={clsx(
|
||||
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Masonry>
|
||||
{loadMore && (
|
||||
<VisibilityObserver
|
||||
|
@ -118,6 +128,7 @@ export function CreatorContractsList(props: {
|
|||
creatorId: creator.id,
|
||||
}}
|
||||
persistPrefix={`user-${creator.id}`}
|
||||
profile={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import clsx from 'clsx'
|
||||
import { ShareIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import React, { useState } from 'react'
|
||||
|
@ -10,7 +8,7 @@ import { ShareModal } from './share-modal'
|
|||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Tooltip } from '../tooltip'
|
||||
|
||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
@ -23,27 +21,23 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
{user?.id !== contract.creatorId && (
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => {
|
||||
setShareOpen(true)
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
|
||||
</Row>
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
contract={contract}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
<Col className={'justify-center'}>
|
||||
<ContractInfoDialog contract={contract} />
|
||||
</Col>
|
||||
<Tooltip text="Share" placement="bottom" noTap noFade>
|
||||
<Button
|
||||
size="sm"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => setShareOpen(true)}
|
||||
>
|
||||
<ShareIcon className="h-5 w-5" aria-hidden />
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
contract={contract}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ContractInfoDialog contract={contract} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { sum } from 'lodash'
|
||||
import { Tooltip } from '../tooltip'
|
||||
|
||||
export function LikeMarketButton(props: {
|
||||
contract: Contract
|
||||
|
@ -37,37 +38,44 @@ export function LikeMarketButton(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
<Tooltip
|
||||
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`}
|
||||
placement="bottom"
|
||||
noTap
|
||||
noFade
|
||||
>
|
||||
<Col className={'relative items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'h-5 w-5 sm:h-6 sm:w-6',
|
||||
totalTipped > 0 ? 'mr-2' : '',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.id) ||
|
||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
{totalTipped > 0 && (
|
||||
<div
|
||||
<Button
|
||||
size={'sm'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
>
|
||||
<Col className={'relative items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
|
||||
totalTipped > 99
|
||||
? 'text-[0.4rem] sm:text-[0.5rem]'
|
||||
: 'sm:text-2xs text-[0.5rem]'
|
||||
'h-5 w-5 sm:h-6 sm:w-6',
|
||||
totalTipped > 0 ? 'mr-2' : '',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.id) ||
|
||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{totalTipped}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Button>
|
||||
/>
|
||||
{totalTipped > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
|
||||
totalTipped > 99
|
||||
? 'text-[0.4rem] sm:text-[0.5rem]'
|
||||
: 'sm:text-2xs text-[0.5rem]'
|
||||
)}
|
||||
>
|
||||
{totalTipped}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { partition } from 'lodash'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
|
@ -8,16 +9,17 @@ import { Row } from '../layout/row'
|
|||
import { LoadingIndicator } from '../loading-indicator'
|
||||
|
||||
export function ProbChangeTable(props: {
|
||||
changes:
|
||||
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
||||
| undefined
|
||||
changes: CPMMContract[] | undefined
|
||||
full?: boolean
|
||||
}) {
|
||||
const { changes, full } = props
|
||||
|
||||
if (!changes) return <LoadingIndicator />
|
||||
|
||||
const { positiveChanges, negativeChanges } = changes
|
||||
const [positiveChanges, negativeChanges] = partition(
|
||||
changes,
|
||||
(c) => c.probChanges.day > 0
|
||||
)
|
||||
|
||||
const threshold = 0.01
|
||||
const positiveAboveThreshold = positiveChanges.filter(
|
||||
|
@ -53,10 +55,18 @@ export function ProbChangeTable(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function ProbChangeRow(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
export function ProbChangeRow(props: {
|
||||
contract: CPMMContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
return (
|
||||
<Row className="items-center justify-between gap-4 hover:bg-gray-100">
|
||||
<Row
|
||||
className={clsx(
|
||||
'items-center justify-between gap-4 hover:bg-gray-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SiteLink
|
||||
className="p-4 pr-0 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
|
|
|
@ -21,6 +21,7 @@ import { CreateChallengeModal } from 'web/components/challenges/create-challenge
|
|||
import { useState } from 'react'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
|
||||
import { QRCode } from '../qr-code'
|
||||
|
||||
export function ShareModal(props: {
|
||||
contract: Contract
|
||||
|
@ -54,6 +55,12 @@ export function ShareModal(props: {
|
|||
</SiteLink>{' '}
|
||||
if a new user signs up using the link!
|
||||
</p>
|
||||
<QRCode
|
||||
url={shareUrl}
|
||||
className="self-center"
|
||||
width={150}
|
||||
height={150}
|
||||
/>
|
||||
<Button
|
||||
size="2xl"
|
||||
color="indigo"
|
||||
|
|
94
web/components/create-post.tsx
Normal file
94
web/components/create-post.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Answer } from 'common/answer'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
|
@ -10,11 +10,10 @@ import clsx from 'clsx'
|
|||
import {
|
||||
ContractCommentInput,
|
||||
FeedComment,
|
||||
ReplyTo,
|
||||
} from 'web/components/feed/feed-comments'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { useRouter } from 'next/router'
|
||||
import { User } from 'common/user'
|
||||
import { useEvent } from 'web/hooks/use-event'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
|
||||
|
@ -27,32 +26,17 @@ export function FeedAnswerCommentGroup(props: {
|
|||
const { answer, contract, answerComments, tips } = props
|
||||
const { username, avatarUrl, name, text } = answer
|
||||
|
||||
const [replyToUser, setReplyToUser] =
|
||||
useState<Pick<User, 'id' | 'username'>>()
|
||||
const [showReply, setShowReply] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<ReplyTo>()
|
||||
const router = useRouter()
|
||||
|
||||
const answerElementId = `answer-${answer.id}`
|
||||
|
||||
const scrollAndOpenReplyInput = useEvent(
|
||||
(comment?: ContractComment, answer?: Answer) => {
|
||||
setReplyToUser(
|
||||
comment
|
||||
? { id: comment.userId, username: comment.userUsername }
|
||||
: answer
|
||||
? { id: answer.userId, username: answer.username }
|
||||
: undefined
|
||||
)
|
||||
setShowReply(true)
|
||||
}
|
||||
)
|
||||
const highlighted = router.asPath.endsWith(`#${answerElementId}`)
|
||||
const answerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (router.asPath.endsWith(`#${answerElementId}`)) {
|
||||
setHighlighted(true)
|
||||
if (highlighted && answerRef.current != null) {
|
||||
answerRef.current.scrollIntoView(true)
|
||||
}
|
||||
}, [answerElementId, router.asPath])
|
||||
}, [highlighted])
|
||||
|
||||
return (
|
||||
<Col className="relative flex-1 items-stretch gap-3">
|
||||
|
@ -61,6 +45,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
'gap-3 space-x-3 pt-4 transition-all duration-1000',
|
||||
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
|
||||
)}
|
||||
ref={answerRef}
|
||||
id={answerElementId}
|
||||
>
|
||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||
|
@ -83,7 +68,9 @@ export function FeedAnswerCommentGroup(props: {
|
|||
<div className="sm:hidden">
|
||||
<button
|
||||
className="text-xs font-bold text-gray-500 hover:underline"
|
||||
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
|
||||
onClick={() =>
|
||||
setReplyTo({ id: answer.id, username: answer.username })
|
||||
}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
|
@ -92,7 +79,9 @@ export function FeedAnswerCommentGroup(props: {
|
|||
<div className="justify-initial hidden sm:block">
|
||||
<button
|
||||
className="text-xs font-bold text-gray-500 hover:underline"
|
||||
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
|
||||
onClick={() =>
|
||||
setReplyTo({ id: answer.id, username: answer.username })
|
||||
}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
|
@ -107,11 +96,13 @@ export function FeedAnswerCommentGroup(props: {
|
|||
contract={contract}
|
||||
comment={comment}
|
||||
tips={tips[comment.id] ?? {}}
|
||||
onReplyClick={scrollAndOpenReplyInput}
|
||||
onReplyClick={() =>
|
||||
setReplyTo({ id: comment.id, username: comment.userUsername })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
{showReply && (
|
||||
{replyTo && (
|
||||
<div className="relative ml-7">
|
||||
<span
|
||||
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
|
@ -120,8 +111,8 @@ export function FeedAnswerCommentGroup(props: {
|
|||
<ContractCommentInput
|
||||
contract={contract}
|
||||
parentAnswerOutcome={answer.number.toString()}
|
||||
replyToUser={replyToUser}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
replyTo={replyTo}
|
||||
onSubmitComment={() => setReplyTo(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ContractComment } from 'common/comment'
|
||||
import { Contract } from 'common/contract'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useRouter } from 'next/router'
|
||||
|
@ -20,6 +20,8 @@ import { Editor } from '@tiptap/react'
|
|||
import { UserLink } from 'web/components/user-link'
|
||||
import { CommentInput } from '../comment-input'
|
||||
|
||||
export type ReplyTo = { id: string; username: string }
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
contract: Contract
|
||||
threadComments: ContractComment[]
|
||||
|
@ -27,13 +29,7 @@ export function FeedCommentThread(props: {
|
|||
parentComment: ContractComment
|
||||
}) {
|
||||
const { contract, threadComments, tips, parentComment } = props
|
||||
const [showReply, setShowReply] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
|
||||
|
||||
function scrollAndOpenReplyInput(comment: ContractComment) {
|
||||
setReplyTo({ id: comment.userId, username: comment.userUsername })
|
||||
setShowReply(true)
|
||||
}
|
||||
const [replyTo, setReplyTo] = useState<ReplyTo>()
|
||||
|
||||
return (
|
||||
<Col className="relative w-full items-stretch gap-3 pb-4">
|
||||
|
@ -48,10 +44,12 @@ export function FeedCommentThread(props: {
|
|||
contract={contract}
|
||||
comment={comment}
|
||||
tips={tips[comment.id] ?? {}}
|
||||
onReplyClick={scrollAndOpenReplyInput}
|
||||
onReplyClick={() =>
|
||||
setReplyTo({ id: comment.id, username: comment.userUsername })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{showReply && (
|
||||
{replyTo && (
|
||||
<Col className="-pb-2 relative ml-6">
|
||||
<span
|
||||
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
|
@ -60,10 +58,8 @@ export function FeedCommentThread(props: {
|
|||
<ContractCommentInput
|
||||
contract={contract}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
onSubmitComment={() => setReplyTo(undefined)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
|
@ -76,7 +72,7 @@ export function FeedComment(props: {
|
|||
comment: ContractComment
|
||||
tips?: CommentTips
|
||||
indent?: boolean
|
||||
onReplyClick?: (comment: ContractComment) => void
|
||||
onReplyClick?: () => void
|
||||
}) {
|
||||
const { contract, comment, tips, indent, onReplyClick } = props
|
||||
const {
|
||||
|
@ -98,16 +94,19 @@ export function FeedComment(props: {
|
|||
money = formatMoney(Math.abs(comment.betAmount))
|
||||
}
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const router = useRouter()
|
||||
const highlighted = router.asPath.endsWith(`#${comment.id}`)
|
||||
const commentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (router.asPath.endsWith(`#${comment.id}`)) {
|
||||
setHighlighted(true)
|
||||
if (highlighted && commentRef.current != null) {
|
||||
commentRef.current.scrollIntoView(true)
|
||||
}
|
||||
}, [comment.id, router.asPath])
|
||||
}, [highlighted])
|
||||
|
||||
return (
|
||||
<Row
|
||||
ref={commentRef}
|
||||
id={comment.id}
|
||||
className={clsx(
|
||||
'relative',
|
||||
|
@ -174,7 +173,7 @@ export function FeedComment(props: {
|
|||
{onReplyClick && (
|
||||
<button
|
||||
className="font-bold hover:underline"
|
||||
onClick={() => onReplyClick(comment)}
|
||||
onClick={onReplyClick}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
|
@ -204,7 +203,7 @@ export function ContractCommentInput(props: {
|
|||
contract: Contract
|
||||
className?: string
|
||||
parentAnswerOutcome?: string | undefined
|
||||
replyToUser?: { id: string; username: string }
|
||||
replyTo?: ReplyTo
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: () => void
|
||||
}) {
|
||||
|
@ -226,7 +225,7 @@ export function ContractCommentInput(props: {
|
|||
|
||||
return (
|
||||
<CommentInput
|
||||
replyToUser={props.replyToUser}
|
||||
replyTo={props.replyTo}
|
||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
||||
parentCommentId={props.parentCommentId}
|
||||
onSubmitComment={onSubmitComment}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { track } from 'web/lib/service/analytics'
|
|||
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||
import { useState } from 'react'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Tooltip } from './tooltip'
|
||||
|
||||
export const FollowMarketButton = (props: {
|
||||
contract: Contract
|
||||
|
@ -23,61 +24,70 @@ export const FollowMarketButton = (props: {
|
|||
const followers = useContractFollows(contract.id)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const watching = followers?.includes(user?.id ?? 'nope')
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
color={'gray-white'}
|
||||
onClick={async () => {
|
||||
if (!user) return firebaseLogin()
|
||||
if (followers?.includes(user.id)) {
|
||||
await unFollowContract(contract.id, user.id)
|
||||
toast("You'll no longer receive notifications from this market", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Unwatch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
} else {
|
||||
await followContract(contract.id, user.id)
|
||||
toast("You'll now receive notifications from this market!", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Watch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
}
|
||||
if (!user.hasSeenContractFollowModal) {
|
||||
await updateUser(user.id, {
|
||||
hasSeenContractFollowModal: true,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
<Tooltip
|
||||
text={watching ? 'Unfollow' : 'Follow'}
|
||||
placement="bottom"
|
||||
noTap
|
||||
noFade
|
||||
>
|
||||
{followers?.includes(user?.id ?? 'nope') ? (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeOffIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Unwatch */}
|
||||
</Col>
|
||||
) : (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Watch */}
|
||||
</Col>
|
||||
)}
|
||||
<WatchMarketModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={`You ${
|
||||
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
|
||||
} a question!`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size={'sm'}
|
||||
color={'gray-white'}
|
||||
onClick={async () => {
|
||||
if (!user) return firebaseLogin()
|
||||
if (followers?.includes(user.id)) {
|
||||
await unFollowContract(contract.id, user.id)
|
||||
toast("You'll no longer receive notifications from this market", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Unwatch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
} else {
|
||||
await followContract(contract.id, user.id)
|
||||
toast("You'll now receive notifications from this market!", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Watch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
}
|
||||
if (!user.hasSeenContractFollowModal) {
|
||||
await updateUser(user.id, {
|
||||
hasSeenContractFollowModal: true,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{watching ? (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeOffIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Unwatch */}
|
||||
</Col>
|
||||
) : (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Watch */}
|
||||
</Col>
|
||||
)}
|
||||
<WatchMarketModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={`You ${
|
||||
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
|
||||
} a question!`}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,16 +13,18 @@ import { useDiscoverUsers } from 'web/hooks/use-users'
|
|||
import { TextButton } from './text-button'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function FollowingButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function FollowingButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const followingIds = useFollows(user.id)
|
||||
const followerIds = useFollowers(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<span className="font-semibold">{followingIds?.length ?? ''}</span>{' '}
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className={clsx('font-semibold')}>
|
||||
{followingIds?.length ?? ''}
|
||||
</span>{' '}
|
||||
Following
|
||||
</TextButton>
|
||||
|
||||
|
@ -69,15 +71,15 @@ export function EditFollowingButton(props: { user: User; className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function FollowersButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function FollowersButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const followingIds = useFollows(user.id)
|
||||
const followerIds = useFollowers(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{followerIds?.length ?? ''}</span>{' '}
|
||||
Followers
|
||||
</TextButton>
|
||||
|
@ -115,6 +117,7 @@ function FollowsDialog(props: {
|
|||
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
tabs={[
|
||||
{
|
||||
title: 'Following',
|
||||
|
|
|
@ -16,29 +16,26 @@ import { usePost } from 'web/hooks/use-post'
|
|||
export function GroupAboutPost(props: {
|
||||
group: Group
|
||||
isEditable: boolean
|
||||
post: Post
|
||||
post: Post | null
|
||||
}) {
|
||||
const { group, isEditable } = props
|
||||
const post = usePost(group.aboutPostId) ?? props.post
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-white p-4 ">
|
||||
{isEditable ? (
|
||||
<RichEditGroupAboutPost group={group} post={post} />
|
||||
) : (
|
||||
<Content content={post.content} />
|
||||
)}
|
||||
{isEditable && <RichEditGroupAboutPost group={group} post={post} />}
|
||||
{!isEditable && post && <Content content={post.content} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
|
||||
function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
|
||||
const { group, post } = props
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
defaultValue: post.content,
|
||||
defaultValue: post?.content,
|
||||
disabled: isSubmitting,
|
||||
})
|
||||
|
||||
|
@ -49,7 +46,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
|
|||
content: editor.getJSON(),
|
||||
}
|
||||
|
||||
if (group.aboutPostId == null) {
|
||||
if (post == null) {
|
||||
const result = await createPost(newPost).catch((e) => {
|
||||
console.error(e)
|
||||
return e
|
||||
|
@ -65,6 +62,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
|
|||
}
|
||||
|
||||
async function deleteGroupAboutPost() {
|
||||
if (post == null) return
|
||||
await deletePost(post)
|
||||
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">
|
||||
<p className="text-sm">
|
||||
No post has been added yet.
|
||||
|
|
|
@ -14,14 +14,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
|
|||
import { GroupLinkItem } from 'web/pages/groups'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export function GroupsButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function GroupsButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const groups = useMemberGroups(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
|
||||
</TextButton>
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
|||
height={250}
|
||||
width={250}
|
||||
className="self-center"
|
||||
alt="Manifold logo"
|
||||
src="/flappy-logo.gif"
|
||||
/>
|
||||
<div className="m-4 max-w-[550px] self-center">
|
||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
|||
import { useRouter, NextRouter } from 'next/router'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Col } from './col'
|
||||
|
||||
type Tab = {
|
||||
title: string
|
||||
|
@ -31,7 +32,7 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
|||
return (
|
||||
<>
|
||||
<nav
|
||||
className={clsx('mb-4 space-x-8 border-b border-gray-200', className)}
|
||||
className={clsx('space-x-8 border-b border-gray-200', className)}
|
||||
aria-label="Tabs"
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
|
@ -55,11 +56,13 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
|||
)}
|
||||
aria-current={activeIndex === i ? 'page' : undefined}
|
||||
>
|
||||
{tab.tabIcon && <span>{tab.tabIcon}</span>}
|
||||
{tab.badge ? (
|
||||
<span className="px-0.5 font-bold">{tab.badge}</span>
|
||||
) : null}
|
||||
{tab.title}
|
||||
<Col>
|
||||
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>}
|
||||
{tab.title}
|
||||
</Col>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
@ -1,28 +1,33 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { Avatar } from './avatar'
|
||||
import { Row } from './layout/row'
|
||||
import { SiteLink } from './site-link'
|
||||
import { Title } from './title'
|
||||
|
||||
export function Leaderboard(props: {
|
||||
interface LeaderboardEntry {
|
||||
username: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export function Leaderboard<T extends LeaderboardEntry>(props: {
|
||||
title: string
|
||||
users: User[]
|
||||
entries: T[]
|
||||
columns: {
|
||||
header: string
|
||||
renderCell: (user: User) => any
|
||||
renderCell: (entry: T) => any
|
||||
}[]
|
||||
className?: string
|
||||
maxToShow?: number
|
||||
}) {
|
||||
// TODO: Ideally, highlight your own entry on the leaderboard
|
||||
const { title, columns, className } = props
|
||||
const maxToShow = props.maxToShow ?? props.users.length
|
||||
const users = props.users.slice(0, maxToShow)
|
||||
const maxToShow = props.maxToShow ?? props.entries.length
|
||||
const entries = props.entries.slice(0, maxToShow)
|
||||
return (
|
||||
<div className={clsx('w-full px-1', className)}>
|
||||
<Title text={title} className="!mt-0" />
|
||||
{users.length === 0 ? (
|
||||
{entries.length === 0 ? (
|
||||
<div className="ml-2 text-gray-500">None yet</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
|
@ -37,19 +42,19 @@ export function Leaderboard(props: {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, index) => (
|
||||
<tr key={user.id}>
|
||||
{entries.map((entry, index) => (
|
||||
<tr key={index}>
|
||||
<td>{index + 1}</td>
|
||||
<td className="max-w-[190px]">
|
||||
<SiteLink className="relative" href={`/${user.username}`}>
|
||||
<SiteLink className="relative" href={`/${entry.username}`}>
|
||||
<Row className="items-center gap-4">
|
||||
<Avatar avatarUrl={user.avatarUrl} size={8} />
|
||||
<div className="truncate">{user.name}</div>
|
||||
<Avatar avatarUrl={entry.avatarUrl} size={8} />
|
||||
<div className="truncate">{entry.name}</div>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
</td>
|
||||
{columns.map((column) => (
|
||||
<td key={column.header}>{column.renderCell(user)}</td>
|
||||
<td key={column.header}>{column.renderCell(entry)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
|
||||
import { Item } from './sidebar-item'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import NotificationsIcon from '../notifications-icon'
|
||||
import router from 'next/router'
|
||||
import { userProfileItem } from './bottom-nav-bar'
|
||||
|
||||
const mobileGroupNavigation = [
|
||||
{ name: 'Markets', key: 'markets', icon: HomeIcon },
|
||||
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
|
||||
{ name: 'About', key: 'about', icon: ClipboardIcon },
|
||||
]
|
||||
|
||||
const mobileGeneralNavigation = [
|
||||
{
|
||||
name: 'Notifications',
|
||||
key: 'notifications',
|
||||
icon: NotificationsIcon,
|
||||
href: '/notifications',
|
||||
},
|
||||
]
|
||||
|
||||
export function GroupNavBar(props: {
|
||||
currentPage: string
|
||||
onClick: (key: string) => void
|
||||
}) {
|
||||
const { currentPage } = props
|
||||
const user = useUser()
|
||||
|
||||
return (
|
||||
<nav className="z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
||||
{mobileGroupNavigation.map((item) => (
|
||||
<NavBarItem
|
||||
key={item.name}
|
||||
item={item}
|
||||
currentPage={currentPage}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{mobileGeneralNavigation.map((item) => (
|
||||
<NavBarItem
|
||||
key={item.name}
|
||||
item={item}
|
||||
currentPage={currentPage}
|
||||
onClick={() => {
|
||||
router.push(item.href)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{user && (
|
||||
<NavBarItem
|
||||
key={'profile'}
|
||||
currentPage={currentPage}
|
||||
onClick={() => {
|
||||
router.push(`/${user.username}?tab=trades`)
|
||||
}}
|
||||
item={userProfileItem(user)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBarItem(props: {
|
||||
item: Item
|
||||
currentPage: string
|
||||
onClick: (key: string) => void
|
||||
}) {
|
||||
const { item, currentPage } = props
|
||||
const track = trackCallback(
|
||||
`group navbar: ${item.trackingEventName ?? item.name}`
|
||||
)
|
||||
|
||||
return (
|
||||
<button onClick={() => props.onClick(item.key ?? '#')}>
|
||||
<a
|
||||
className={clsx(
|
||||
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||
currentPage === item.key && 'bg-gray-200 text-indigo-700'
|
||||
)}
|
||||
onClick={track}
|
||||
>
|
||||
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||
{item.name}
|
||||
</a>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import React from 'react'
|
||||
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||
import { SignInButton } from '../sign-in-button'
|
||||
import NotificationsIcon from '../notifications-icon'
|
||||
import { SidebarItem } from './sidebar-item'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import { User } from 'common/user'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
|
||||
const groupNavigation = [
|
||||
{ name: 'Markets', key: 'markets', icon: HomeIcon },
|
||||
{ name: 'About', key: 'about', icon: ClipboardIcon },
|
||||
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
|
||||
]
|
||||
|
||||
const generalNavigation = (user?: User | null) =>
|
||||
buildArray(
|
||||
user && {
|
||||
name: 'Notifications',
|
||||
href: `/notifications`,
|
||||
key: 'notifications',
|
||||
icon: NotificationsIcon,
|
||||
}
|
||||
)
|
||||
|
||||
export function GroupSidebar(props: {
|
||||
groupName: string
|
||||
className?: string
|
||||
onClick: (key: string) => void
|
||||
joinOrAddQuestionsButton: React.ReactNode
|
||||
currentKey: string
|
||||
}) {
|
||||
const { className, groupName, currentKey } = props
|
||||
|
||||
const user = useUser()
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Group Sidebar"
|
||||
className={clsx('flex max-h-[100vh] flex-col', className)}
|
||||
>
|
||||
<ManifoldLogo className="pt-6" twoLine />
|
||||
<Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row>
|
||||
|
||||
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
|
||||
{user ? (
|
||||
<ProfileSummary user={user} />
|
||||
) : (
|
||||
<SignInButton className="mb-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
{groupNavigation.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
currentPage={currentKey}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
))}
|
||||
{generalNavigation(user).map((item) => (
|
||||
<SidebarItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
currentPage={currentKey}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Spacer h={2} />
|
||||
|
||||
{props.joinOrAddQuestionsButton}
|
||||
</nav>
|
||||
)
|
||||
}
|
|
@ -26,9 +26,14 @@ import TrophyIcon from 'web/lib/icons/trophy-icon'
|
|||
import { SignInButton } from '../sign-in-button'
|
||||
import { SidebarItem } from './sidebar-item'
|
||||
import { MoreButton } from './more-button'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
|
||||
export default function Sidebar(props: { className?: string }) {
|
||||
const { className } = props
|
||||
export default function Sidebar(props: {
|
||||
className?: string
|
||||
logoSubheading?: string
|
||||
}) {
|
||||
const { className, logoSubheading } = props
|
||||
const router = useRouter()
|
||||
const currentPage = router.pathname
|
||||
|
||||
|
@ -51,7 +56,13 @@ export default function Sidebar(props: { className?: string }) {
|
|||
aria-label="Sidebar"
|
||||
className={clsx('flex max-h-[100vh] flex-col', className)}
|
||||
>
|
||||
<ManifoldLogo className="py-6" twoLine />
|
||||
<ManifoldLogo className="pt-6" twoLine />
|
||||
{logoSubheading && (
|
||||
<Row className="pl-2 text-2xl text-indigo-700 sm:mt-3">
|
||||
{logoSubheading}
|
||||
</Row>
|
||||
)}
|
||||
<Spacer h={6} />
|
||||
|
||||
{!user && <SignInButton className="mb-4" />}
|
||||
|
||||
|
|
|
@ -99,8 +99,6 @@ const useIsTwitch = (user: User | null | undefined) => {
|
|||
const isTwitch = router.pathname === '/twitch'
|
||||
|
||||
useEffect(() => {
|
||||
console.log('twich?', isTwitch)
|
||||
|
||||
if (isTwitch && user?.shouldShowWelcome) {
|
||||
updateUser(user.id, { ['shouldShowWelcome']: false })
|
||||
}
|
||||
|
|
|
@ -9,8 +9,15 @@ export function Page(props: {
|
|||
className?: string
|
||||
rightSidebarClassName?: string
|
||||
children?: ReactNode
|
||||
logoSubheading?: string
|
||||
}) {
|
||||
const { children, rightSidebar, className, rightSidebarClassName } = props
|
||||
const {
|
||||
children,
|
||||
rightSidebar,
|
||||
className,
|
||||
rightSidebarClassName,
|
||||
logoSubheading,
|
||||
} = props
|
||||
|
||||
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
|
||||
return (
|
||||
|
@ -23,7 +30,10 @@ export function Page(props: {
|
|||
)}
|
||||
>
|
||||
<Toaster />
|
||||
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" />
|
||||
<Sidebar
|
||||
logoSubheading={logoSubheading}
|
||||
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
|
||||
/>
|
||||
<main
|
||||
className={clsx(
|
||||
'lg:col-span-8 lg:pt-6',
|
||||
|
|
|
@ -1,72 +1,155 @@
|
|||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import dayjs from 'dayjs'
|
||||
import { last } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { formatTime } from 'web/lib/util/time'
|
||||
import { Col } from '../layout/col'
|
||||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
mode: 'value' | 'profit'
|
||||
handleGraphDisplayChange: (arg0: string | number | null) => void
|
||||
height?: number
|
||||
includeTime?: boolean
|
||||
}) {
|
||||
const { portfolioHistory, height, includeTime, mode } = props
|
||||
const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
const profit = value - totalDeposits
|
||||
const valuePoints = getPoints('value', portfolioHistory)
|
||||
const posProfitPoints = getPoints('posProfit', portfolioHistory)
|
||||
const negProfitPoints = getPoints('negProfit', portfolioHistory)
|
||||
|
||||
const valuePointsY = valuePoints.map((p) => p.y)
|
||||
const posProfitPointsY = posProfitPoints.map((p) => p.y)
|
||||
const negProfitPointsY = negProfitPoints.map((p) => p.y)
|
||||
|
||||
let data
|
||||
|
||||
if (mode === 'value') {
|
||||
data = [{ id: 'value', data: valuePoints, color: '#4f46e5' }]
|
||||
} else {
|
||||
data = [
|
||||
{
|
||||
id: 'negProfit',
|
||||
data: negProfitPoints,
|
||||
color: '#dc2626',
|
||||
},
|
||||
{
|
||||
id: 'posProfit',
|
||||
data: posProfitPoints,
|
||||
color: '#14b8a6',
|
||||
},
|
||||
]
|
||||
}
|
||||
const numYTickValues = 2
|
||||
const endDate = last(data[0].data)?.x
|
||||
|
||||
const yMin =
|
||||
mode === 'value'
|
||||
? Math.min(...filterDefined(valuePointsY))
|
||||
: Math.min(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
|
||||
const yMax =
|
||||
mode === 'value'
|
||||
? Math.max(...filterDefined(valuePointsY))
|
||||
: Math.max(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
|
||||
return {
|
||||
x: new Date(timestamp),
|
||||
y: mode === 'value' ? value : profit,
|
||||
}
|
||||
})
|
||||
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const numYTickValues = 4
|
||||
const endDate = last(points)?.x
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
|
||||
style={{ height: height ?? (!width || width >= 800 ? 200 : 100) }}
|
||||
onMouseLeave={() => handleGraphDisplayChange(null)}
|
||||
>
|
||||
<ResponsiveLine
|
||||
margin={{ top: 10, right: 0, left: 40, bottom: 10 }}
|
||||
data={data}
|
||||
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: points[0]?.x,
|
||||
min: valuePoints[0]?.x,
|
||||
max: endDate,
|
||||
}}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
stacked: false,
|
||||
min: Math.min(...points.map((p) => p.y)),
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
}}
|
||||
gridYValues={numYTickValues}
|
||||
curve="stepAfter"
|
||||
enablePoints={false}
|
||||
colors={{ datum: 'color' }}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, !!includeTime),
|
||||
tickValues: 0,
|
||||
}}
|
||||
pointBorderColor="#fff"
|
||||
pointSize={points.length > 100 ? 0 : 6}
|
||||
pointSize={valuePoints.length > 100 ? 0 : 6}
|
||||
axisLeft={{
|
||||
tickValues: numYTickValues,
|
||||
format: (value) => formatMoney(value),
|
||||
format: '.3s',
|
||||
}}
|
||||
enableGridX={!!width && width >= 800}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
gridYValues={numYTickValues}
|
||||
enableSlices="x"
|
||||
animate={false}
|
||||
yFormat={(value) => formatMoney(+value)}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
sliceTooltip={({ slice }) => {
|
||||
handleGraphDisplayChange(slice.points[0].data.yFormatted)
|
||||
return (
|
||||
<div className="rounded bg-white px-4 py-2 opacity-80">
|
||||
<div
|
||||
key={slice.points[0].id}
|
||||
className="text-xs font-semibold sm:text-sm"
|
||||
>
|
||||
<Col>
|
||||
<div>
|
||||
{dayjs(slice.points[0].data.xFormatted).format('MMM/D/YY')}
|
||||
</div>
|
||||
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
|
||||
{dayjs(slice.points[0].data.xFormatted).format('h:mm A')}
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{/* ))} */}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
></ResponsiveLine>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function getPoints(
|
||||
line: 'value' | 'posProfit' | 'negProfit',
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
) {
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
|
||||
const profit = value - totalDeposits
|
||||
let posProfit = null
|
||||
let negProfit = null
|
||||
if (profit < 0) {
|
||||
negProfit = profit
|
||||
} else {
|
||||
posProfit = profit
|
||||
}
|
||||
|
||||
return {
|
||||
x: new Date(timestamp),
|
||||
y:
|
||||
line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit,
|
||||
}
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { last } from 'lodash'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { Period } from 'web/lib/firebase/users'
|
||||
import { PillButton } from '../buttons/pill-button'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
|
@ -14,6 +15,13 @@ export const PortfolioValueSection = memo(
|
|||
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
|
||||
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value')
|
||||
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
|
||||
number | string | null
|
||||
>(null)
|
||||
const handleGraphDisplayChange = (num: string | number | null) => {
|
||||
setGraphDisplayNumber(num)
|
||||
}
|
||||
|
||||
// Remember the last defined portfolio history.
|
||||
const portfolioRef = useRef(portfolioHistory)
|
||||
|
@ -28,43 +36,144 @@ export const PortfolioValueSection = memo(
|
|||
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
|
||||
const totalValue = balance + investmentValue
|
||||
const totalProfit = totalValue - totalDeposits
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="gap-8">
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Profit</div>
|
||||
<div className="text-lg">{formatMoney(totalProfit)}</div>
|
||||
</Col>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
value={portfolioPeriod}
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="monthly">Last Month</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
<option value="daily">Last 24h</option>
|
||||
</select>
|
||||
<Row className="mb-2 justify-between">
|
||||
<Row className="gap-4 sm:gap-8">
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer',
|
||||
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
|
||||
)}
|
||||
onClick={() => setGraphMode('value')}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||
Portfolio value
|
||||
</div>
|
||||
<div className={clsx('text-lg text-indigo-600 sm:text-xl')}>
|
||||
{graphMode === 'value'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber
|
||||
: formatMoney(totalValue)
|
||||
: formatMoney(totalValue)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer',
|
||||
graphMode != 'profit'
|
||||
? 'cursor-pointer opacity-40 hover:opacity-80'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => setGraphMode('profit')}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">Profit</div>
|
||||
<div
|
||||
className={clsx(
|
||||
graphMode === 'profit'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber.toString().includes('-')
|
||||
? 'text-red-600'
|
||||
: 'text-teal-500'
|
||||
: totalProfit > 0
|
||||
? 'text-teal-500'
|
||||
: 'text-red-600'
|
||||
: totalProfit > 0
|
||||
? 'text-teal-500'
|
||||
: 'text-red-600',
|
||||
'text-lg sm:text-xl'
|
||||
)}
|
||||
>
|
||||
{graphMode === 'profit'
|
||||
? graphDisplayNumber
|
||||
? graphDisplayNumber
|
||||
: formatMoney(totalProfit)
|
||||
: formatMoney(totalProfit)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="profit"
|
||||
mode={graphMode}
|
||||
handleGraphDisplayChange={handleGraphDisplayChange}
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
</Col>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="value"
|
||||
<PortfolioPeriodSelection
|
||||
portfolioPeriod={portfolioPeriod}
|
||||
setPortfolioPeriod={setPortfolioPeriod}
|
||||
className="border-greyscale-2 mt-2 gap-4 border-b"
|
||||
selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export function PortfolioPeriodSelection(props: {
|
||||
setPortfolioPeriod: (string: any) => void
|
||||
portfolioPeriod: string
|
||||
className?: string
|
||||
selectClassName?: string
|
||||
}) {
|
||||
const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } =
|
||||
props
|
||||
return (
|
||||
<Row className={clsx(className, 'text-greyscale-4')}>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('daily' as Period)}
|
||||
>
|
||||
1D
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('weekly' as Period)}
|
||||
>
|
||||
1W
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('monthly' as Period)}
|
||||
>
|
||||
1M
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('allTime' as Period)}
|
||||
>
|
||||
ALL
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function GraphToggle(props: {
|
||||
setGraphMode: (mode: 'profit' | 'value') => void
|
||||
graphMode: string
|
||||
}) {
|
||||
const { setGraphMode, graphMode } = props
|
||||
return (
|
||||
<Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<PillButton
|
||||
selected={graphMode === 'value'}
|
||||
onSelect={() => {
|
||||
setGraphMode('value')
|
||||
}}
|
||||
xs={true}
|
||||
className="z-50"
|
||||
>
|
||||
Value
|
||||
</PillButton>
|
||||
<PillButton
|
||||
selected={graphMode === 'profit'}
|
||||
onSelect={() => {
|
||||
setGraphMode('profit')
|
||||
}}
|
||||
xs={true}
|
||||
className="z-50"
|
||||
>
|
||||
Profit
|
||||
</PillButton>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,15 +10,15 @@ import { XIcon } from '@heroicons/react/outline'
|
|||
import { unLikeContract } from 'web/lib/firebase/likes'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
|
||||
export function UserLikesButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
export function UserLikesButton(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const likedContracts = useUserLikedContracts(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '}
|
||||
Likes
|
||||
</TextButton>
|
||||
|
|
|
@ -13,14 +13,18 @@ import { getUser, updateUser } from 'web/lib/firebase/users'
|
|||
import { TextButton } from 'web/components/text-button'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
|
||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
export function ReferralsButton(props: {
|
||||
user: User
|
||||
currentUser?: User
|
||||
className?: string
|
||||
}) {
|
||||
const { user, currentUser, className } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const referralIds = useReferrals(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<TextButton onClick={() => setIsOpen(true)} className={className}>
|
||||
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
|
||||
Referrals
|
||||
</TextButton>
|
||||
|
@ -64,6 +68,7 @@ function ReferralsDialog(props: {
|
|||
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
tabs={[
|
||||
{
|
||||
title: 'Referrals',
|
||||
|
|
|
@ -16,6 +16,8 @@ import { track } from 'web/lib/service/analytics'
|
|||
import { Row } from './layout/row'
|
||||
import { Tooltip } from './tooltip'
|
||||
|
||||
const TIP_SIZE = 10
|
||||
|
||||
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||
const { comment, tips } = prop
|
||||
|
||||
|
@ -82,9 +84,12 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
|
||||
return (
|
||||
<Row className="items-center gap-0.5">
|
||||
<DownTip onClick={canDown ? () => addTip(-5) : undefined} />
|
||||
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} />
|
||||
<span className="font-bold">{Math.floor(total)}</span>
|
||||
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} />
|
||||
<UpTip
|
||||
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined}
|
||||
value={localTip}
|
||||
/>
|
||||
{localTip === 0 ? (
|
||||
''
|
||||
) : (
|
||||
|
@ -107,7 +112,7 @@ function DownTip(props: { onClick?: () => void }) {
|
|||
<Tooltip
|
||||
className="h-6 w-6"
|
||||
placement="bottom"
|
||||
text={onClick && `-${formatMoney(5)}`}
|
||||
text={onClick && `-${formatMoney(TIP_SIZE)}`}
|
||||
noTap
|
||||
>
|
||||
<button
|
||||
|
@ -128,7 +133,7 @@ function UpTip(props: { onClick?: () => void; value: number }) {
|
|||
<Tooltip
|
||||
className="h-6 w-6"
|
||||
placement="bottom"
|
||||
text={onClick && `Tip ${formatMoney(5)}`}
|
||||
text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
|
||||
noTap
|
||||
>
|
||||
<button
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { NextRouter, useRouter } from 'next/router'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import {
|
||||
ChatIcon,
|
||||
FolderIcon,
|
||||
PencilIcon,
|
||||
ScaleIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -24,39 +29,23 @@ import { FollowersButton, FollowingButton } from './following-button'
|
|||
import { UserFollowButton } from './follow-button'
|
||||
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||
import { ReferralsButton } from 'web/components/referrals-button'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import {
|
||||
BettingStreakModal,
|
||||
hasCompletedStreakToday,
|
||||
} from 'web/components/profile/betting-streak-modal'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
import { LoansModal } from './profile/loans-modal'
|
||||
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
import { capitalize } from 'lodash'
|
||||
|
||||
export function UserPage(props: { user: User }) {
|
||||
const { user } = props
|
||||
const router = useRouter()
|
||||
const currentUser = useUser()
|
||||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
setShowBettingStreakModal(showBettingStreak)
|
||||
setShowConfetti(claimedMana || showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
|
||||
setShowConfetti(claimedMana)
|
||||
const query = { ...router.query }
|
||||
if (query.claimedMana || query.show) {
|
||||
delete query['claimed-mana']
|
||||
|
@ -85,217 +74,159 @@ export function UserPage(props: { user: User }) {
|
|||
{showConfetti && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
)}
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${bannerUrl})`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative mb-20">
|
||||
<div className="absolute -top-10 left-4">
|
||||
<Col className="relative">
|
||||
<Row className="relative px-4 pt-4">
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={24}
|
||||
className="bg-white ring-4 ring-white"
|
||||
className="bg-white shadow-sm shadow-indigo-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top right buttons (e.g. edit, follow) */}
|
||||
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
<div className="absolute z-50 ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300">
|
||||
<SiteLink href="/profile">
|
||||
<PencilIcon className="h-5" />{' '}
|
||||
</SiteLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||
<Col>
|
||||
<span className="break-anywhere text-2xl font-bold">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
<Row className={'gap-3'}>
|
||||
<Col className={'items-center text-gray-500'}>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-md',
|
||||
profit >= 0 ? 'text-green-600' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
<Col className="w-full gap-4 pl-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<Col>
|
||||
<span className="break-anywhere text-lg font-bold sm:text-2xl">
|
||||
{user.name}
|
||||
</span>
|
||||
<span>profit</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'cursor-pointer items-center text-gray-500',
|
||||
isCurrentUser && !hasCompletedStreakToday(user)
|
||||
? 'grayscale'
|
||||
: 'grayscale-0'
|
||||
)}
|
||||
onClick={() => setShowBettingStreakModal(true)}
|
||||
>
|
||||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||
<span>streak</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={
|
||||
'flex-shrink-0 cursor-pointer items-center text-gray-500'
|
||||
}
|
||||
onClick={() => setShowLoansModal(true)}
|
||||
>
|
||||
<span className="text-green-600">
|
||||
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||
<span className="sm:text-md text-greyscale-4 text-sm">
|
||||
@{user.username}
|
||||
</span>
|
||||
<span>next loan</span>
|
||||
</Col>
|
||||
</Row>
|
||||
{isCurrentUser && (
|
||||
<ProfilePrivateStats
|
||||
currentUser={currentUser}
|
||||
profit={profit}
|
||||
user={user}
|
||||
router={router}
|
||||
/>
|
||||
)}
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
</div>
|
||||
<ProfilePublicStats
|
||||
className="sm:text-md text-greyscale-6 hidden text-sm md:inline"
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer h={4} />
|
||||
{user.bio && (
|
||||
<>
|
||||
<div>
|
||||
<Linkify text={user.bio}></Linkify>
|
||||
</div>
|
||||
<Spacer h={4} />
|
||||
</>
|
||||
)}
|
||||
{(user.website || user.twitterHandle || user.discordHandle) && (
|
||||
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-sm text-gray-500">{user.website}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
|
||||
<Row
|
||||
className={
|
||||
'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<SiteLink href="/referrals">
|
||||
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
||||
</SiteLink>{' '}
|
||||
You've gotten{' '}
|
||||
<ReferralsButton user={user} currentUser={currentUser} />
|
||||
</span>
|
||||
<ShareIconButton
|
||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||
toastClassName={'sm:-left-40 -left-40 min-w-[250%]'}
|
||||
buttonClassName={'h-10 w-10'}
|
||||
iconClassName={'h-8 w-8 text-indigo-700'}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<QueryUncontrolledTabs
|
||||
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-6'}>
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
<ReferralsButton user={user} />
|
||||
<GroupsButton user={user} />
|
||||
<UserLikesButton user={user} />
|
||||
<Col className="mx-4 mt-2">
|
||||
<Spacer h={1} />
|
||||
<ProfilePublicStats
|
||||
className="text-greyscale-6 text-sm md:hidden"
|
||||
user={user}
|
||||
/>
|
||||
<Spacer h={1} />
|
||||
{user.bio && (
|
||||
<>
|
||||
<div className="sm:text-md mt-2 text-sm sm:mt-0">
|
||||
<Linkify text={user.bio}></Linkify>
|
||||
</div>
|
||||
<Spacer h={2} />
|
||||
</>
|
||||
)}
|
||||
{(user.website || user.twitterHandle || user.discordHandle) && (
|
||||
<Row className="mb-2 flex-wrap items-center gap-2 sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink
|
||||
href={
|
||||
'https://' +
|
||||
user.website.replace('http://', '').replace('https://', '')
|
||||
}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.website}
|
||||
</span>
|
||||
</Row>
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink
|
||||
href={`https://twitter.com/${user.twitterHandle
|
||||
.replace('https://www.twitter.com/', '')
|
||||
.replace('https://twitter.com/', '')
|
||||
.replace('www.twitter.com/', '')
|
||||
.replace('twitter.com/', '')}`}
|
||||
>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-greyscale-4 text-sm">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
<QueryUncontrolledTabs
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 sm:pt-4 '}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Portfolio',
|
||||
tabIcon: <FolderIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
<Spacer h={4} />
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Markets',
|
||||
tabIcon: <ScaleIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<CreatorContractsList user={currentUser} creator={user} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Comments',
|
||||
tabIcon: <ChatIcon className="h-5" />,
|
||||
content: (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<Col>
|
||||
<UserCommentsList user={user} />
|
||||
</Col>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
@ -313,3 +244,88 @@ export function defaultBannerUrl(userId: string) {
|
|||
]
|
||||
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
||||
}
|
||||
|
||||
export function ProfilePrivateStats(props: {
|
||||
currentUser: User | null | undefined
|
||||
profit: number
|
||||
user: User
|
||||
router: NextRouter
|
||||
}) {
|
||||
const { currentUser, profit, user, router } = props
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
||||
setShowBettingStreakModal(showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<Row className={'justify-between gap-4 sm:justify-end'}>
|
||||
<Col className={'text-greyscale-4 text-md sm:text-lg'}>
|
||||
<span
|
||||
className={clsx(profit >= 0 ? 'text-green-600' : 'text-red-400')}
|
||||
>
|
||||
{formatMoney(profit)}
|
||||
</span>
|
||||
<span className="mx-auto text-xs sm:text-sm">profit</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx('text-,d cursor-pointer sm:text-lg ')}
|
||||
onClick={() => setShowBettingStreakModal(true)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
!hasCompletedStreakToday(user)
|
||||
? 'opacity-50 grayscale'
|
||||
: 'grayscale-0'
|
||||
)}
|
||||
>
|
||||
🔥 {user.currentBettingStreak ?? 0}
|
||||
</span>
|
||||
<span className="text-greyscale-4 mx-auto text-xs sm:text-sm">
|
||||
streak
|
||||
</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={
|
||||
'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
|
||||
}
|
||||
onClick={() => setShowLoansModal(true)}
|
||||
>
|
||||
<span className="text-green-600">
|
||||
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||
</span>
|
||||
<span className="mx-auto text-xs sm:text-sm">next loan</span>
|
||||
</Col>
|
||||
</Row>
|
||||
{BettingStreakModal && (
|
||||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePublicStats(props: { user: User; className?: string }) {
|
||||
const { user, className } = props
|
||||
return (
|
||||
<Row className={'flex-wrap items-center gap-3'}>
|
||||
<FollowingButton user={user} className={className} />
|
||||
<FollowersButton user={user} className={className} />
|
||||
{/* <ReferralsButton user={user} className={className} /> */}
|
||||
<GroupsButton user={user} className={className} />
|
||||
{/* <UserLikesButton user={user} className={className} /> */}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -9,12 +9,13 @@ import {
|
|||
getUserBetContractsQuery,
|
||||
listAllContracts,
|
||||
trendingContractsQuery,
|
||||
getContractsQuery,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { QueryClient, useQueryClient } from 'react-query'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { QueryClient, useQuery, useQueryClient } from 'react-query'
|
||||
import { MINUTE_MS, sleep } from 'common/util/time'
|
||||
import { query, limit } from 'firebase/firestore'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
import { dailyScoreIndex } from 'web/lib/service/algolia'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { zipObject } from 'lodash'
|
||||
|
||||
export const useContracts = () => {
|
||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||
|
@ -26,6 +27,29 @@ export const useContracts = () => {
|
|||
return contracts
|
||||
}
|
||||
|
||||
export const useContractsByDailyScoreGroups = (
|
||||
groupSlugs: string[] | undefined
|
||||
) => {
|
||||
const facetFilters = ['isResolved:false']
|
||||
|
||||
const { data } = useQuery(['daily-score', groupSlugs], () =>
|
||||
Promise.all(
|
||||
(groupSlugs ?? []).map((slug) =>
|
||||
dailyScoreIndex.search<CPMMBinaryContract>('', {
|
||||
facetFilters: [...facetFilters, `groupLinks.slug:${slug}`],
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!groupSlugs || !data || data.length !== groupSlugs.length)
|
||||
return undefined
|
||||
|
||||
return zipObject(
|
||||
groupSlugs,
|
||||
data.map((d) => d.hits.filter((c) => c.dailyScore))
|
||||
)
|
||||
}
|
||||
|
||||
const q = new QueryClient()
|
||||
export const getCachedContracts = async () =>
|
||||
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
|
||||
|
@ -40,19 +64,6 @@ export const useTrendingContracts = (maxContracts: number) => {
|
|||
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 = () => {
|
||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||
|
||||
|
@ -75,7 +86,7 @@ export const usePrefetchUserBetContracts = (userId: string) => {
|
|||
const queryClient = useQueryClient()
|
||||
return queryClient.prefetchQuery(
|
||||
['contracts', 'bets', userId],
|
||||
() => getUserBetContracts(userId),
|
||||
() => sleep(1000).then(() => getUserBetContracts(userId)),
|
||||
{ staleTime: 5 * MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -104,7 +104,7 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
|||
}
|
||||
|
||||
export function useMemberGroupsSubscription(user: User | null | undefined) {
|
||||
const cachedGroups = useMemberGroups(user?.id) ?? []
|
||||
const cachedGroups = useMemberGroups(user?.id)
|
||||
const [groups, setGroups] = useState(cachedGroups)
|
||||
|
||||
const userId = user?.id
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// matches talwind sm breakpoint
|
||||
export function useIsMobile() {
|
||||
const { width } = useWindowSize()
|
||||
return (width ?? 0) < 640
|
||||
export function useIsMobile(threshold?: number) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>()
|
||||
useEffect(() => {
|
||||
// 640 matches tailwind sm breakpoint
|
||||
const onResize = () => setIsMobile(window.innerWidth < (threshold ?? 640))
|
||||
onResize()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [threshold])
|
||||
return isMobile
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useQueryClient } from 'react-query'
|
||||
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 {
|
||||
getPortfolioHistory,
|
||||
getPortfolioHistoryQuery,
|
||||
|
@ -17,7 +17,7 @@ export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
|
|||
const cutoff = getCutoff(period)
|
||||
return queryClient.prefetchQuery(
|
||||
['portfolio-history', userId, cutoff],
|
||||
() => getPortfolioHistory(userId, cutoff),
|
||||
() => sleep(1000).then(() => getPortfolioHistory(userId, cutoff)),
|
||||
{ staleTime: 15 * MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,3 +11,29 @@ export const usePost = (postId: string | undefined) => {
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { usePrefetchUserBetContracts } from './use-contracts'
|
||||
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
|
||||
import { usePrefetchUserBets } from './use-user-bets'
|
||||
|
||||
export function usePrefetch(userId: string | undefined) {
|
||||
|
@ -7,6 +6,5 @@ export function usePrefetch(userId: string | undefined) {
|
|||
return Promise.all([
|
||||
usePrefetchUserBets(maybeUserId),
|
||||
usePrefetchUserBetContracts(maybeUserId),
|
||||
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
|
||||
])
|
||||
}
|
||||
|
|
|
@ -1,75 +1,47 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { useQuery, useQueryClient } from 'react-query'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { sortBy, uniqBy } from 'lodash'
|
||||
import { useQuery } from 'react-query'
|
||||
import {
|
||||
getProbChangesNegative,
|
||||
getProbChangesPositive,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { getValues } from 'web/lib/firebase/utils'
|
||||
import { getIndexName, searchClient } from 'web/lib/service/algolia'
|
||||
probChangeAscendingIndex,
|
||||
probChangeDescendingIndex,
|
||||
} from 'web/lib/service/algolia'
|
||||
|
||||
export const useProbChangesAlgolia = (userId: string) => {
|
||||
const { data: positiveData } = useQuery(['prob-change-day', userId], () =>
|
||||
searchClient
|
||||
.initIndex(getIndexName('prob-change-day'))
|
||||
.search<CPMMContract>('', {
|
||||
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
|
||||
})
|
||||
)
|
||||
const { data: negativeData } = useQuery(
|
||||
['prob-change-day-ascending', userId],
|
||||
() =>
|
||||
searchClient
|
||||
.initIndex(getIndexName('prob-change-day-ascending'))
|
||||
.search<CPMMContract>('', {
|
||||
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
|
||||
})
|
||||
)
|
||||
export const useProbChanges = (
|
||||
filters: { bettorId?: string; groupSlugs?: string[] } = {}
|
||||
) => {
|
||||
const { bettorId, groupSlugs } = filters
|
||||
|
||||
if (!positiveData || !negativeData) {
|
||||
return undefined
|
||||
const bettorFilter = bettorId ? `uniqueBettorIds:${bettorId}` : ''
|
||||
const groupFilters = groupSlugs
|
||||
? groupSlugs.map((slug) => `groupLinks.slug:${slug}`)
|
||||
: []
|
||||
|
||||
const facetFilters = [
|
||||
'isResolved:false',
|
||||
'outcomeType:BINARY',
|
||||
bettorFilter,
|
||||
groupFilters,
|
||||
]
|
||||
const searchParams = {
|
||||
facetFilters,
|
||||
hitsPerPage: 50,
|
||||
}
|
||||
|
||||
return {
|
||||
positiveChanges: positiveData.hits
|
||||
.filter((c) => c.probChanges && c.probChanges.day > 0)
|
||||
.filter((c) => c.outcomeType === 'BINARY'),
|
||||
negativeChanges: negativeData.hits
|
||||
.filter((c) => c.probChanges && c.probChanges.day < 0)
|
||||
.filter((c) => c.outcomeType === 'BINARY'),
|
||||
}
|
||||
}
|
||||
|
||||
export const useProbChanges = (userId: string) => {
|
||||
const { data: positiveChanges } = useFirestoreQueryData(
|
||||
['prob-changes-day-positive', userId],
|
||||
getProbChangesPositive(userId)
|
||||
)
|
||||
const { data: negativeChanges } = useFirestoreQueryData(
|
||||
['prob-changes-day-negative', userId],
|
||||
getProbChangesNegative(userId)
|
||||
)
|
||||
|
||||
if (!positiveChanges || !negativeChanges) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { positiveChanges, negativeChanges }
|
||||
}
|
||||
|
||||
export const usePrefetchProbChanges = (userId: string | undefined) => {
|
||||
const queryClient = useQueryClient()
|
||||
if (userId) {
|
||||
queryClient.prefetchQuery(
|
||||
['prob-changes-day-positive', userId],
|
||||
() => getValues(getProbChangesPositive(userId)),
|
||||
{ staleTime: MINUTE_MS }
|
||||
)
|
||||
queryClient.prefetchQuery(
|
||||
['prob-changes-day-negative', userId],
|
||||
() => getValues(getProbChangesNegative(userId)),
|
||||
{ staleTime: MINUTE_MS }
|
||||
)
|
||||
}
|
||||
const { data: positiveChanges } = useQuery(
|
||||
['prob-change-day', groupSlugs],
|
||||
() => probChangeDescendingIndex.search<CPMMBinaryContract>('', searchParams)
|
||||
)
|
||||
const { data: negativeChanges } = useQuery(
|
||||
['prob-change-day-ascending', groupSlugs],
|
||||
() => probChangeAscendingIndex.search<CPMMBinaryContract>('', searchParams)
|
||||
)
|
||||
|
||||
if (!positiveChanges || !negativeChanges) return undefined
|
||||
|
||||
const hits = uniqBy(
|
||||
[...positiveChanges.hits, ...negativeChanges.hits],
|
||||
(c) => c.id
|
||||
).filter((c) => c.probChanges)
|
||||
|
||||
return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse()
|
||||
}
|
||||
|
|
|
@ -7,13 +7,13 @@ import {
|
|||
getUserBetsQuery,
|
||||
listenForUserContractBets,
|
||||
} 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) => {
|
||||
const queryClient = useQueryClient()
|
||||
return queryClient.prefetchQuery(
|
||||
['bets', userId],
|
||||
() => getUserBets(userId),
|
||||
() => sleep(1000).then(() => getUserBets(userId)),
|
||||
{ staleTime: MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -90,6 +90,10 @@ export function getCurrentUser(params: any) {
|
|||
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)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { partition, sortBy, sum, uniqBy } from 'lodash'
|
||||
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
||||
import { BinaryContract, Contract } from 'common/contract'
|
||||
import { chooseRandomSubset } from 'common/util/random'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
|
@ -24,7 +24,6 @@ import { Bet } from 'common/bet'
|
|||
import { Comment } from 'common/comment'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { getBinaryProb } from 'common/contract-details'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
|
||||
export const contracts = coll<Contract>('contracts')
|
||||
|
||||
|
@ -321,51 +320,6 @@ export const getTopGroupContracts = async (
|
|||
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 (
|
||||
contract: Contract,
|
||||
excludeBettorId: string,
|
||||
|
@ -426,21 +380,3 @@ export async function getRecentBetsAndComments(contract: Contract) {
|
|||
recentComments,
|
||||
}
|
||||
}
|
||||
|
||||
export const getProbChangesPositive = (userId: string) =>
|
||||
query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId),
|
||||
where('probChanges.day', '>', 0),
|
||||
orderBy('probChanges.day', 'desc'),
|
||||
limit(10)
|
||||
) as Query<CPMMContract>
|
||||
|
||||
export const getProbChangesNegative = (userId: string) =>
|
||||
query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId),
|
||||
where('probChanges.day', '<', 0),
|
||||
orderBy('probChanges.day', 'asc'),
|
||||
limit(10)
|
||||
) as Query<CPMMContract>
|
||||
|
|
|
@ -43,6 +43,7 @@ export function groupPath(
|
|||
| 'about'
|
||||
| typeof GROUP_CHAT_SLUG
|
||||
| 'leaderboards'
|
||||
| 'posts'
|
||||
) {
|
||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||
}
|
||||
|
|
|
@ -39,3 +39,8 @@ export function listenForPost(
|
|||
) {
|
||||
return listenForValue(doc(posts, postId), setPost)
|
||||
}
|
||||
|
||||
export async function listPosts(postIds?: string[]) {
|
||||
if (postIds === undefined) return []
|
||||
return Promise.all(postIds.map(getPost))
|
||||
}
|
||||
|
|
|
@ -13,3 +13,13 @@ export const searchIndexName =
|
|||
export const getIndexName = (sort: string) => {
|
||||
return `${indexPrefix}contracts-${sort}`
|
||||
}
|
||||
|
||||
export const probChangeDescendingIndex = searchClient.initIndex(
|
||||
getIndexName('prob-change-day')
|
||||
)
|
||||
export const probChangeAscendingIndex = searchClient.initIndex(
|
||||
getIndexName('prob-change-day-ascending')
|
||||
)
|
||||
export const dailyScoreIndex = searchClient.initIndex(
|
||||
getIndexName('daily-score')
|
||||
)
|
||||
|
|
|
@ -9,9 +9,6 @@ module.exports = {
|
|||
reactStrictMode: true,
|
||||
optimizeFonts: false,
|
||||
experimental: {
|
||||
images: {
|
||||
allowFutureImage: true,
|
||||
},
|
||||
scrollRestoration: true,
|
||||
externalDir: true,
|
||||
modularizeImports: {
|
||||
|
|
|
@ -23,9 +23,9 @@
|
|||
"@floating-ui/react-dom-interactions": "0.9.2",
|
||||
"@headlessui/react": "1.6.1",
|
||||
"@heroicons/react": "1.0.5",
|
||||
"@nivo/core": "0.74.0",
|
||||
"@nivo/line": "0.74.0",
|
||||
"@nivo/tooltip": "0.74.0",
|
||||
"@nivo/core": "0.80.0",
|
||||
"@nivo/line": "0.80.0",
|
||||
"@nivo/tooltip": "0.80.0",
|
||||
"@react-query-firebase/firestore": "0.4.2",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||
|
@ -46,7 +46,7 @@
|
|||
"gridjs-react": "5.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"nanoid": "^3.3.4",
|
||||
"next": "12.2.5",
|
||||
"next": "12.3.1",
|
||||
"node-fetch": "3.2.4",
|
||||
"prosemirror-state": "1.4.1",
|
||||
"react": "17.0.2",
|
||||
|
@ -58,6 +58,7 @@
|
|||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0",
|
||||
"react-twitter-embed": "4.0.4",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"string-similarity": "^4.0.4",
|
||||
"tippy.js": "6.3.7"
|
||||
},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
@ -17,7 +17,6 @@ import {
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Bet, listAllBets } from 'web/lib/firebase/bets'
|
||||
import { listAllComments } from 'web/lib/firebase/comments'
|
||||
import Custom404 from '../404'
|
||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
|
@ -32,8 +31,6 @@ import { CPMMBinaryContract } from 'common/contract'
|
|||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { User } from 'common/user'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { getOpenGraphProps } from 'common/contract-details'
|
||||
import { ContractDescription } from 'web/components/contract/contract-description'
|
||||
import {
|
||||
|
@ -54,25 +51,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
|
|||
export async function getStaticPropz(props: {
|
||||
params: { username: string; contractSlug: string }
|
||||
}) {
|
||||
const { username, contractSlug } = props.params
|
||||
const { contractSlug } = props.params
|
||||
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||
const contractId = contract?.id
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
])
|
||||
const bets = contractId ? await listAllBets(contractId) : []
|
||||
|
||||
return {
|
||||
props: {
|
||||
contract,
|
||||
username,
|
||||
slug: contractSlug,
|
||||
// Limit the data sent to the client. Client will still load all bets and comments directly.
|
||||
bets: bets.slice(0, 5000),
|
||||
comments: comments.slice(0, 1000),
|
||||
},
|
||||
|
||||
// Limit the data sent to the client. Client will still load all bets directly.
|
||||
props: { contract, bets: bets.slice(0, 5000) },
|
||||
revalidate: 5, // regenerate after five seconds
|
||||
}
|
||||
}
|
||||
|
@ -83,21 +69,11 @@ export async function getStaticPaths() {
|
|||
|
||||
export default function ContractPage(props: {
|
||||
contract: Contract | null
|
||||
username: string
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
slug: string
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
contract: null,
|
||||
username: '',
|
||||
comments: [],
|
||||
bets: [],
|
||||
slug: '',
|
||||
}
|
||||
props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
|
||||
|
||||
const user = useUser()
|
||||
const inIframe = useIsIframe()
|
||||
if (inIframe) {
|
||||
return <ContractEmbedPage {...props} />
|
||||
|
@ -109,9 +85,7 @@ export default function ContractPage(props: {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
|
||||
)
|
||||
return <ContractPageContent key={contract.id} {...{ ...props, contract }} />
|
||||
}
|
||||
|
||||
// requires an admin to resolve a week after market closes
|
||||
|
@ -119,12 +93,10 @@ export function needsAdminToResolve(contract: Contract) {
|
|||
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
|
||||
}
|
||||
|
||||
export function ContractPageSidebar(props: {
|
||||
user: User | null | undefined
|
||||
contract: Contract
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
export function ContractPageSidebar(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { creatorId, isResolved, outcomeType } = contract
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
@ -173,11 +145,11 @@ export function ContractPageSidebar(props: {
|
|||
export function ContractPageContent(
|
||||
props: Parameters<typeof ContractPage>[0] & {
|
||||
contract: Contract
|
||||
user?: User | null
|
||||
}
|
||||
) {
|
||||
const { backToHome, comments, user } = props
|
||||
const { backToHome } = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const user = useUser()
|
||||
usePrefetch(user?.id)
|
||||
useTracking(
|
||||
'view market',
|
||||
|
@ -217,9 +189,8 @@ export function ContractPageContent(
|
|||
contractId: contract.id,
|
||||
})
|
||||
|
||||
const rightSidebar = <ContractPageSidebar user={user} contract={contract} />
|
||||
return (
|
||||
<Page rightSidebar={rightSidebar}>
|
||||
<Page rightSidebar={<ContractPageSidebar contract={contract} />}>
|
||||
{showConfetti && (
|
||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||
)}
|
||||
|
@ -228,7 +199,7 @@ export function ContractPageContent(
|
|||
<SEO
|
||||
title={question}
|
||||
description={ogCardProps.description}
|
||||
url={`/${props.username}/${props.slug}`}
|
||||
url={`/${contract.creatorUsername}/${contract.slug}`}
|
||||
ogCardProps={ogCardProps}
|
||||
/>
|
||||
)}
|
||||
|
@ -271,22 +242,13 @@ export function ContractPageContent(
|
|||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||
<ContractLeaderboard contract={contract} bets={bets} />
|
||||
<ContractTopTrades
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
comments={comments}
|
||||
/>
|
||||
<ContractTopTrades contract={contract} bets={bets} />
|
||||
</div>
|
||||
<Spacer h={12} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContractTabs
|
||||
contract={contract}
|
||||
user={user}
|
||||
bets={bets}
|
||||
comments={comments}
|
||||
/>
|
||||
<ContractTabs contract={contract} bets={bets} />
|
||||
{!user ? (
|
||||
<Col className="mt-4 max-w-sm items-center xl:hidden">
|
||||
<BetSignUpPrompt />
|
||||
|
@ -307,26 +269,28 @@ export function ContractPageContent(
|
|||
)
|
||||
}
|
||||
|
||||
function RecommendedContractsWidget(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const user = useUser()
|
||||
const [recommendations, setRecommendations] = useState<Contract[]>([])
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getRecommendedContracts(contract, user.id, 6).then(setRecommendations)
|
||||
const RecommendedContractsWidget = memo(
|
||||
function RecommendedContractsWidget(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const user = useUser()
|
||||
const [recommendations, setRecommendations] = useState<Contract[]>([])
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getRecommendedContracts(contract, user.id, 6).then(setRecommendations)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contract.id, user?.id])
|
||||
if (recommendations.length === 0) {
|
||||
return null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contract.id, user?.id])
|
||||
if (recommendations.length === 0) {
|
||||
return null
|
||||
return (
|
||||
<Col className="mt-2 gap-2 px-2 sm:px-0">
|
||||
<Title className="text-gray-700" text="Recommended" />
|
||||
<ContractsGrid
|
||||
contracts={recommendations}
|
||||
trackingPostfix=" recommended"
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Col className="mt-2 gap-2 px-2 sm:px-0">
|
||||
<Title className="text-gray-700" text="Recommended" />
|
||||
<ContractsGrid
|
||||
contracts={recommendations}
|
||||
trackingPostfix=" recommended"
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useEffect } from 'react'
|
|||
import Head from 'next/head'
|
||||
import Script from 'next/script'
|
||||
import { QueryClient, QueryClientProvider } from 'react-query'
|
||||
import { AuthProvider } from 'web/components/auth-context'
|
||||
import { AuthProvider, AuthUser } from 'web/components/auth-context'
|
||||
import Welcome from 'web/components/onboarding/welcome'
|
||||
|
||||
function firstLine(msg: string) {
|
||||
|
@ -24,7 +24,10 @@ function printBuildInfo() {
|
|||
}
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
// specially treated props that may be present in the server/static props
|
||||
type ManifoldPageProps = { auth?: AuthUser }
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
|
||||
useEffect(printBuildInfo, [])
|
||||
|
||||
return (
|
||||
|
@ -78,7 +81,7 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
</Head>
|
||||
<AuthProvider serverUser={pageProps.auth}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Welcome {...pageProps} />
|
||||
<Welcome />
|
||||
<Component {...pageProps} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
|
|
|
@ -24,14 +24,14 @@ export default function AddFundsPage() {
|
|||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Get Manifold Dollars"
|
||||
description="Get Manifold Dollars"
|
||||
title="Get Mana"
|
||||
description="Buy mana to trade in your favorite markets on Manifold"
|
||||
url="/add-funds"
|
||||
/>
|
||||
|
||||
<Col className="items-center">
|
||||
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
|
||||
<Title className="!mt-0" text="Get Manifold Dollars" />
|
||||
<Title className="!mt-0" text="Get Mana" />
|
||||
<img
|
||||
className="mb-6 block -scale-x-100 self-center"
|
||||
src="/stylized-crane-black.png"
|
||||
|
@ -40,8 +40,8 @@ export default function AddFundsPage() {
|
|||
/>
|
||||
|
||||
<div className="mb-6 text-gray-500">
|
||||
Purchase Manifold Dollars to trade in your favorite markets. <br />{' '}
|
||||
(Not redeemable for cash.)
|
||||
Buy mana (M$) to trade in your favorite markets. <br /> (Not
|
||||
redeemable for cash.)
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-sm text-gray-500">Amount</div>
|
||||
|
|
28
web/pages/api/v0/market/[id]/close.ts
Normal file
28
web/pages/api/v0/market/[id]/close.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import {
|
||||
CORS_ORIGIN_MANIFOLD,
|
||||
CORS_ORIGIN_LOCALHOST,
|
||||
} from 'common/envs/constants'
|
||||
import { applyCorsHeaders } from 'web/lib/api/cors'
|
||||
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
|
||||
|
||||
export const config = { api: { bodyParser: true } }
|
||||
|
||||
export default async function route(req: NextApiRequest, res: NextApiResponse) {
|
||||
await applyCorsHeaders(req, res, {
|
||||
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
methods: 'POST',
|
||||
})
|
||||
|
||||
const { id } = req.query
|
||||
const contractId = id as string
|
||||
|
||||
if (req.body) req.body.contractId = contractId
|
||||
try {
|
||||
const backendRes = await fetchBackend(req, 'closemarket')
|
||||
await forwardResponse(res, backendRes)
|
||||
} catch (err) {
|
||||
console.error('Error talking to cloud function: ', err)
|
||||
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||
}
|
||||
}
|
|
@ -92,7 +92,7 @@ export default function ChallengesListPage() {
|
|||
tap the button above to create a new market & challenge in one.
|
||||
</p>
|
||||
|
||||
<Tabs tabs={[...userTab, ...publicTab]} />
|
||||
<Tabs className="mb-4" tabs={[...userTab, ...publicTab]} />
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -2,14 +2,17 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
||||
export default function DailyMovers() {
|
||||
const user = useUser()
|
||||
const bettorId = user?.id ?? undefined
|
||||
|
||||
const changes = useProbChangesAlgolia(user?.id ?? '')
|
||||
const changes = useProbChanges({ bettorId })?.filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
|
||||
useTracking('view daily movers')
|
||||
|
||||
|
|
|
@ -34,20 +34,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
|
|||
export async function getStaticPropz(props: {
|
||||
params: { username: string; contractSlug: string }
|
||||
}) {
|
||||
const { username, contractSlug } = props.params
|
||||
const { contractSlug } = props.params
|
||||
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||
const contractId = contract?.id
|
||||
|
||||
const bets = contractId ? await listAllBets(contractId) : []
|
||||
|
||||
return {
|
||||
props: {
|
||||
contract,
|
||||
username,
|
||||
slug: contractSlug,
|
||||
bets,
|
||||
},
|
||||
|
||||
props: { contract, bets },
|
||||
revalidate: 60, // regenerate after a minute
|
||||
}
|
||||
}
|
||||
|
@ -58,16 +52,9 @@ export async function getStaticPaths() {
|
|||
|
||||
export default function ContractEmbedPage(props: {
|
||||
contract: Contract | null
|
||||
username: string
|
||||
bets: Bet[]
|
||||
slug: string
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
contract: null,
|
||||
username: '',
|
||||
bets: [],
|
||||
slug: '',
|
||||
}
|
||||
props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
|
||||
|
||||
const contract = useContractWithPreload(props.contract)
|
||||
const { bets } = props
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||
|
@ -16,7 +16,7 @@ import {
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import {
|
||||
useGroup,
|
||||
useGroupContractIds,
|
||||
|
@ -42,17 +42,21 @@ import { GroupComment } from 'common/comment'
|
|||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
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 { 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 { track } from '@amplitude/analytics-browser'
|
||||
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/solid'
|
||||
import { GroupSidebar } from 'web/components/nav/group-sidebar'
|
||||
import { SelectMarketsModal } from 'web/components/contract-select-modal'
|
||||
import { BETTORS } from 'common/user'
|
||||
import { Page } from 'web/components/page'
|
||||
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 async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -70,7 +74,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
? 'all'
|
||||
: 'open'
|
||||
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 cachedTopTraderIds =
|
||||
|
@ -83,6 +88,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
|
||||
const creator = await creatorPromise
|
||||
|
||||
const posts = ((group && (await listPosts(group.postIds))) ?? []).filter(
|
||||
(p) => p != null
|
||||
) as Post[]
|
||||
return {
|
||||
props: {
|
||||
group,
|
||||
|
@ -93,6 +101,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
messages,
|
||||
aboutPost,
|
||||
suggestedFilter,
|
||||
posts,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -107,17 +116,19 @@ const groupSubpages = [
|
|||
'markets',
|
||||
'leaderboards',
|
||||
'about',
|
||||
'posts',
|
||||
] as const
|
||||
|
||||
export default function GroupPage(props: {
|
||||
group: Group | null
|
||||
memberIds: string[]
|
||||
creator: User
|
||||
creator: User | null
|
||||
topTraders: { user: User; score: number }[]
|
||||
topCreators: { user: User; score: number }[]
|
||||
messages: GroupComment[]
|
||||
aboutPost: Post
|
||||
aboutPost: Post | null
|
||||
suggestedFilter: 'open' | 'all'
|
||||
posts: Post[]
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
group: null,
|
||||
|
@ -127,37 +138,43 @@ export default function GroupPage(props: {
|
|||
topCreators: [],
|
||||
messages: [],
|
||||
suggestedFilter: 'open',
|
||||
posts: [],
|
||||
}
|
||||
const { creator, topTraders, topCreators, suggestedFilter } = props
|
||||
const { creator, topTraders, topCreators, suggestedFilter, posts } = props
|
||||
|
||||
const router = useRouter()
|
||||
const { slugs } = router.query as { slugs: string[] }
|
||||
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 aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost
|
||||
|
||||
let groupPosts = usePosts(group?.postIds ?? []) ?? posts
|
||||
|
||||
if (aboutPost != null) {
|
||||
groupPosts = [aboutPost, ...groupPosts]
|
||||
}
|
||||
|
||||
const user = useUser()
|
||||
const isAdmin = useAdmin()
|
||||
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||
// Note: Keep in sync with sidebarPages
|
||||
const [sidebarIndex, setSidebarIndex] = useState(
|
||||
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
|
||||
)
|
||||
|
||||
useSaveReferral(user, {
|
||||
defaultReferrerUsername: creator.username,
|
||||
defaultReferrerUsername: creator?.username,
|
||||
groupId: group?.id,
|
||||
})
|
||||
|
||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||
if (group === null || !groupSubpages.includes(page) || slugs[2] || !creator) {
|
||||
return <Custom404 />
|
||||
}
|
||||
const isCreator = user && group && user.id === group.creatorId
|
||||
const isMember = user && memberIds.includes(user.id)
|
||||
const maxLeaderboardSize = 50
|
||||
|
||||
const leaderboardPage = (
|
||||
const leaderboardTab = (
|
||||
<Col>
|
||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||
<GroupLeaderboard
|
||||
|
@ -176,7 +193,17 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const aboutPage = (
|
||||
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 = (
|
||||
<Col>
|
||||
{(group.aboutPostId != null || isCreator || isAdmin) && (
|
||||
<GroupAboutPost
|
||||
|
@ -196,16 +223,21 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const questionsPage = (
|
||||
const questionsTab = (
|
||||
<>
|
||||
{/* align the divs to the right */}
|
||||
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
|
||||
<div>
|
||||
<JoinOrAddQuestionsButtons
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
<div className={'flex justify-end '}>
|
||||
<div
|
||||
className={
|
||||
'flex items-end justify-self-end px-2 md:absolute md:top-0 md:pb-2'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<JoinOrAddQuestionsButtons
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContractSearch
|
||||
|
@ -215,92 +247,47 @@ export default function GroupPage(props: {
|
|||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
includeProbSorts
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const sidebarPages = [
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Markets',
|
||||
content: questionsPage,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
key: 'markets',
|
||||
content: questionsTab,
|
||||
},
|
||||
{
|
||||
title: 'Leaderboards',
|
||||
content: leaderboardPage,
|
||||
href: groupPath(group.slug, 'leaderboards'),
|
||||
key: 'leaderboards',
|
||||
content: leaderboardTab,
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
content: aboutPage,
|
||||
href: groupPath(group.slug, 'about'),
|
||||
key: 'about',
|
||||
content: aboutTab,
|
||||
},
|
||||
{
|
||||
title: 'Posts',
|
||||
content: postsPage,
|
||||
},
|
||||
]
|
||||
|
||||
const pageContent = sidebarPages[sidebarIndex].content
|
||||
const onSidebarClick = (key: string) => {
|
||||
const index = sidebarPages.findIndex((t) => t.key === key)
|
||||
setSidebarIndex(index)
|
||||
// Append the page to the URL, e.g. /group/mexifold/markets
|
||||
router.replace(
|
||||
{ query: { ...router.query, slugs: [group.slug, key] } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
)
|
||||
}
|
||||
|
||||
const joinOrAddQuestionsButton = (
|
||||
<JoinOrAddQuestionsButtons
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopGroupNavBar
|
||||
group={group}
|
||||
currentPage={sidebarPages[sidebarIndex].key}
|
||||
onClick={onSidebarClick}
|
||||
<Page logoSubheading={group.name}>
|
||||
<SEO
|
||||
title={group.name}
|
||||
description={`Created by ${creator.name}. ${group.about}`}
|
||||
url={groupPath(group.slug)}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
|
||||
}
|
||||
>
|
||||
<Toaster />
|
||||
<GroupSidebar
|
||||
groupName={group.name}
|
||||
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
|
||||
onClick={onSidebarClick}
|
||||
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
|
||||
currentKey={sidebarPages[sidebarIndex].key}
|
||||
/>
|
||||
|
||||
<SEO
|
||||
title={group.name}
|
||||
description={`Created by ${creator.name}. ${group.about}`}
|
||||
url={groupPath(group.slug)}
|
||||
/>
|
||||
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
|
||||
{pageContent}
|
||||
</main>
|
||||
</div>
|
||||
<TopGroupNavBar group={group} />
|
||||
<div className={'relative p-2 pt-0 md:pt-2'}>
|
||||
{/* TODO: Switching tabs should also update the group path */}
|
||||
<Tabs className={'mb-2'} tabs={tabs} defaultIndex={tabIndex} />
|
||||
</div>
|
||||
</>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export function TopGroupNavBar(props: {
|
||||
group: Group
|
||||
currentPage: string
|
||||
onClick: (key: string) => void
|
||||
}) {
|
||||
export function TopGroupNavBar(props: { group: Group }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12">
|
||||
<div className="flex items-center bg-white px-4">
|
||||
|
@ -317,7 +304,6 @@ export function TopGroupNavBar(props: {
|
|||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
@ -330,11 +316,13 @@ function JoinOrAddQuestionsButtons(props: {
|
|||
}) {
|
||||
const { group, user, isMember } = props
|
||||
return user && isMember ? (
|
||||
<Row className={'w-full self-start pt-4'}>
|
||||
<Row className={'mb-2 w-full self-start md:mt-2 '}>
|
||||
<AddContractButton group={group} user={user} />
|
||||
</Row>
|
||||
) : group.anyoneCanJoin ? (
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
<div className="mb-2 md:mb-0">
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
|
@ -451,7 +439,7 @@ function GroupLeaderboard(props: {
|
|||
return (
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
users={topUsers.map((t) => t.user)}
|
||||
entries={topUsers.map((t) => t.user)}
|
||||
title={title}
|
||||
columns={[
|
||||
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },
|
||||
|
@ -461,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 }) {
|
||||
const { group, user } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
|
@ -99,8 +99,35 @@ export default function Groups(props: {
|
|||
</div>
|
||||
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
currentPageForAnalytics={'groups'}
|
||||
tabs={[
|
||||
{
|
||||
title: 'All',
|
||||
content: (
|
||||
<Col>
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search groups"
|
||||
value={query}
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{matchesOrderedByMostContractAndMembers.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
creator={creatorsDict[group.creatorId]}
|
||||
user={user}
|
||||
isMember={memberGroupIds.includes(group.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
),
|
||||
},
|
||||
...(user
|
||||
? [
|
||||
{
|
||||
|
@ -135,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>
|
||||
|
|
|
@ -28,7 +28,7 @@ export default function Home() {
|
|||
}
|
||||
|
||||
const groups = useMemberGroupsSubscription(user)
|
||||
const { sections } = getHomeItems(groups, homeSections)
|
||||
const { sections } = getHomeItems(groups ?? [], homeSections)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
import { Dictionary } from 'lodash'
|
||||
|
||||
import { Page } from 'web/components/page'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
@ -31,11 +32,10 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
|||
import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { ProfitBadge } from 'web/components/bets-list'
|
||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
|
||||
import { useContractsQuery } from 'web/hooks/use-contracts'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
import { PillButton } from 'web/components/buttons/pill-button'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
|
@ -43,6 +43,9 @@ import { updateUser } from 'web/lib/firebase/users'
|
|||
import { isArray, keyBy } from 'lodash'
|
||||
import { usePrefetch } from 'web/hooks/use-prefetch'
|
||||
import { Title } from 'web/components/title'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
@ -52,22 +55,28 @@ export default function Home() {
|
|||
useSaveReferral()
|
||||
usePrefetch(user?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
// Go to landing page if not logged in.
|
||||
Router.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const groups = useMemberGroupsSubscription(user)
|
||||
|
||||
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
|
||||
const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
user &&
|
||||
!user.homeSections &&
|
||||
sections.length > 0 &&
|
||||
groups.length > 0
|
||||
) {
|
||||
if (user && !user.homeSections && sections.length > 0 && groups) {
|
||||
// Save initial home sections.
|
||||
updateUser(user.id, { homeSections: sections.map((s) => s.id) })
|
||||
}
|
||||
}, [user, sections, groups])
|
||||
|
||||
const groupContracts = useContractsByDailyScoreGroups(
|
||||
groups?.map((g) => g.slug)
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Toaster />
|
||||
|
@ -81,9 +90,17 @@ export default function Home() {
|
|||
<DailyStats user={user} />
|
||||
</Row>
|
||||
|
||||
{sections.map((section) => renderSection(section, user, groups))}
|
||||
{!user ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
{sections.map((section) =>
|
||||
renderSection(section, user, groups, groupContracts)
|
||||
)}
|
||||
|
||||
<TrendingGroupsSection user={user} />
|
||||
<TrendingGroupsSection user={user} />
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -101,9 +118,9 @@ export default function Home() {
|
|||
|
||||
const HOME_SECTIONS = [
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
{ label: 'Daily trending', id: 'daily-trending' },
|
||||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'New', id: 'newest' },
|
||||
{ label: 'Recently updated', id: 'recently-updated-for-you' },
|
||||
]
|
||||
|
||||
export const getHomeItems = (groups: Group[], sections: string[]) => {
|
||||
|
@ -122,6 +139,10 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
|
|||
|
||||
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.
|
||||
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
|
||||
|
||||
|
@ -133,19 +154,20 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
|
|||
|
||||
function renderSection(
|
||||
section: { id: string; label: string },
|
||||
user: User | null | undefined,
|
||||
groups: Group[]
|
||||
user: User,
|
||||
groups: Group[] | undefined,
|
||||
groupContracts: Dictionary<CPMMBinaryContract[]> | undefined
|
||||
) {
|
||||
const { id, label } = section
|
||||
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 (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={label}
|
||||
sort={'last-updated'}
|
||||
sort={'daily-score'}
|
||||
pill="personal"
|
||||
user={user}
|
||||
/>
|
||||
|
@ -156,8 +178,23 @@ function renderSection(
|
|||
<SearchSection key={id} label={label} sort={sort.value} user={user} />
|
||||
)
|
||||
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group) return <GroupSection key={id} group={group} user={user} />
|
||||
if (groups && groupContracts) {
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group) {
|
||||
const contracts = groupContracts[group.slug].filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
if (contracts.length === 0) return null
|
||||
return (
|
||||
<GroupSection
|
||||
key={id}
|
||||
group={group}
|
||||
user={user}
|
||||
contracts={contracts}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
@ -189,7 +226,7 @@ function SectionHeader(props: {
|
|||
|
||||
function SearchSection(props: {
|
||||
label: string
|
||||
user: User | null | undefined | undefined
|
||||
user: User
|
||||
sort: Sort
|
||||
pill?: string
|
||||
}) {
|
||||
|
@ -207,7 +244,6 @@ function SearchSection(props: {
|
|||
defaultPill={pill}
|
||||
noControls
|
||||
maxResults={6}
|
||||
headerClassName="sticky"
|
||||
persistPrefix={`home-${sort}`}
|
||||
/>
|
||||
</Col>
|
||||
|
@ -216,11 +252,10 @@ function SearchSection(props: {
|
|||
|
||||
function GroupSection(props: {
|
||||
group: Group
|
||||
user: User | null | undefined | undefined
|
||||
user: User
|
||||
contracts: CPMMBinaryContract[]
|
||||
}) {
|
||||
const { group, user } = props
|
||||
|
||||
const contracts = useContractsQuery('score', 4, { groupSlug: group.slug })
|
||||
const { group, user, contracts } = props
|
||||
|
||||
return (
|
||||
<Col>
|
||||
|
@ -228,39 +263,37 @@ function GroupSection(props: {
|
|||
<Button
|
||||
color="gray-white"
|
||||
onClick={() => {
|
||||
if (user) {
|
||||
const homeSections = (user.homeSections ?? []).filter(
|
||||
(id) => id !== group.id
|
||||
)
|
||||
updateUser(user.id, { homeSections })
|
||||
const homeSections = (user.homeSections ?? []).filter(
|
||||
(id) => id !== group.id
|
||||
)
|
||||
updateUser(user.id, { homeSections })
|
||||
|
||||
toast.promise(leaveGroup(group, user.id), {
|
||||
loading: 'Unfollowing group...',
|
||||
success: `Unfollowed ${group.name}`,
|
||||
error: "Couldn't unfollow group, try again?",
|
||||
})
|
||||
}
|
||||
toast.promise(leaveGroup(group, user.id), {
|
||||
loading: 'Unfollowing group...',
|
||||
success: `Unfollowed ${group.name}`,
|
||||
error: "Couldn't unfollow group, try again?",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
|
||||
</Button>
|
||||
</SectionHeader>
|
||||
<ContractsGrid contracts={contracts} />
|
||||
<ContractsGrid
|
||||
contracts={contracts.slice(0, 4)}
|
||||
cardUIOptions={{ showProbChange: true }}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyMoversSection(props: { userId: string | null | undefined }) {
|
||||
const { userId } = props
|
||||
const changes = useProbChangesAlgolia(userId ?? '')
|
||||
const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter(
|
||||
(c) => Math.abs(c.probChanges.day) >= 0.01
|
||||
)
|
||||
|
||||
if (changes) {
|
||||
const { positiveChanges, negativeChanges } = changes
|
||||
if (
|
||||
!positiveChanges.find((c) => c.probChanges.day >= 0.01) ||
|
||||
!negativeChanges.find((c) => c.probChanges.day <= -0.01)
|
||||
)
|
||||
return null
|
||||
if (changes && changes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -332,6 +365,10 @@ export function TrendingGroupsSection(props: {
|
|||
const count = full ? 100 : 25
|
||||
const chosenGroups = groups.slice(0, count)
|
||||
|
||||
if (chosenGroups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={className}>
|
||||
<SectionHeader label="Trending groups" href="/explore-groups">
|
||||
|
|
|
@ -81,7 +81,7 @@ export default function Leaderboards(_props: {
|
|||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||
<Leaderboard
|
||||
title={`🏅 Top ${BETTORS}`}
|
||||
users={topTraders}
|
||||
entries={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
|
@ -92,7 +92,7 @@ export default function Leaderboards(_props: {
|
|||
|
||||
<Leaderboard
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
entries={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total bet',
|
||||
|
@ -106,7 +106,7 @@ export default function Leaderboards(_props: {
|
|||
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="🏅 Top followed"
|
||||
users={topFollowed}
|
||||
entries={topFollowed}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total followers',
|
||||
|
@ -132,6 +132,7 @@ export default function Leaderboards(_props: {
|
|||
/>
|
||||
<Title text={'Leaderboards'} className={'hidden md:block'} />
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
currentPageForAnalytics={'leaderboards'}
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
|
|
|
@ -971,6 +971,8 @@ function ContractResolvedNotification(props: {
|
|||
const { sourceText, data } = notification
|
||||
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
|
||||
const subtitle = 'resolved the market'
|
||||
const profitable = userPayout >= userInvestment
|
||||
const ROI = (userPayout - userInvestment) / userInvestment
|
||||
|
||||
const resolutionDescription = () => {
|
||||
if (!sourceText) return <div />
|
||||
|
@ -1002,23 +1004,21 @@ function ContractResolvedNotification(props: {
|
|||
|
||||
const description =
|
||||
userInvestment && userPayout !== undefined ? (
|
||||
<Row className={'gap-1 '}>
|
||||
Resolved: {resolutionDescription()}
|
||||
Invested:
|
||||
<>
|
||||
Resolved: {resolutionDescription()} Invested:
|
||||
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
|
||||
Payout:
|
||||
<span
|
||||
className={clsx(
|
||||
userPayout > 0 ? 'text-primary' : 'text-red-500',
|
||||
'truncate'
|
||||
profitable ? 'text-primary' : 'text-red-500',
|
||||
'truncate text-ellipsis'
|
||||
)}
|
||||
>
|
||||
{formatMoney(userPayout)}
|
||||
{` (${userPayout > 0 ? '+' : ''}${Math.round(
|
||||
((userPayout - userInvestment) / userInvestment) * 100
|
||||
)}%)`}
|
||||
{userPayout > 0 &&
|
||||
` (${profitable ? '+' : ''}${Math.round(ROI * 100)}%)`}
|
||||
</span>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<span>Resolved {resolutionDescription()}</span>
|
||||
)
|
||||
|
@ -1038,9 +1038,7 @@ function ContractResolvedNotification(props: {
|
|||
highlighted={highlighted}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
<Row>
|
||||
<span>{description}</span>
|
||||
</Row>
|
||||
<Row className={'line-clamp-2 space-x-1'}>{description}</Row>
|
||||
</NotificationFrame>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import { Page } from 'web/components/page'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { Title } from 'web/components/title'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||
import { changeUserInfo } from 'web/lib/firebase/api'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
|
@ -176,27 +175,6 @@ export default function ProfilePage(props: {
|
|||
onBlur={updateUsername}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Allow users with M$ 2000 of assets to set custom banners */}
|
||||
{/* <EditUserField
|
||||
user={user}
|
||||
field="bannerUrl"
|
||||
label="Banner Url"
|
||||
isEditing={isEditing}
|
||||
/> */}
|
||||
<label className="label">
|
||||
Banner image{' '}
|
||||
<span className="text-sm text-gray-400">Not editable for now</span>
|
||||
</label>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
user.bannerUrl || defaultBannerUrl(user.id)
|
||||
})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{(
|
||||
[
|
||||
['bio', 'Bio'],
|
||||
|
|
|
@ -26,6 +26,7 @@ export default function Analytics() {
|
|||
return (
|
||||
<Page>
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
currentPageForAnalytics={'stats'}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -89,6 +90,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
<Spacer h={4} />
|
||||
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -141,6 +143,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
period?
|
||||
</p>
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -198,6 +201,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
<Spacer h={4} />
|
||||
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={2}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -239,6 +243,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
|
||||
<Title text="Daily activity" />
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={0}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -293,6 +298,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
<Spacer h={4} />
|
||||
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -323,6 +329,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
|
||||
<Title text="Ratio of Active Users" />
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
|
@ -367,6 +374,7 @@ export function CustomAnalytics(props: Stats) {
|
|||
Sum of bet amounts. (Divided by 100 to be more readable.)
|
||||
</p>
|
||||
<Tabs
|
||||
className="mb-4"
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
|
|
|
@ -83,14 +83,14 @@ const tourneys: Tourney[] = [
|
|||
endTime: toDate('Sep 30, 2022'),
|
||||
groupId: 'fhksfIgqyWf7OxsV9nkM',
|
||||
},
|
||||
{
|
||||
title: 'Manifold F2P Tournament',
|
||||
blurb:
|
||||
'Who can amass the most mana starting from a free-to-play (F2P) account?',
|
||||
award: 'Poem',
|
||||
endTime: toDate('Sep 15, 2022'),
|
||||
groupId: '6rrIja7tVW00lUVwtsYS',
|
||||
},
|
||||
// {
|
||||
// title: 'Manifold F2P Tournament',
|
||||
// blurb:
|
||||
// 'Who can amass the most mana starting from a free-to-play (F2P) account?',
|
||||
// award: 'Poem',
|
||||
// endTime: toDate('Sep 15, 2022'),
|
||||
// groupId: '6rrIja7tVW00lUVwtsYS',
|
||||
// },
|
||||
// {
|
||||
// title: 'Cause Exploration Prizes',
|
||||
// blurb:
|
||||
|
@ -107,7 +107,14 @@ const tourneys: Tourney[] = [
|
|||
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
||||
},
|
||||
|
||||
// Tournaments without awards get featured belows
|
||||
// Tournaments without awards get featured below
|
||||
{
|
||||
title: 'Criticism and Red Teaming Contest',
|
||||
blurb:
|
||||
'Which criticisms of Effective Altruism have been the most valuable?',
|
||||
endTime: toDate('Sep 30, 2022'),
|
||||
groupId: 'K86LmEmidMKdyCHdHNv4',
|
||||
},
|
||||
{
|
||||
title: 'SF 2022 Ballot',
|
||||
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
||||
|
|
|
@ -146,7 +146,8 @@ function TwitchPlaysManifoldMarkets(props: {
|
|||
<div>
|
||||
Instead of Twitch channel points we use our own play money, mana (M$).
|
||||
All viewers start with M$1,000 and can earn more for free by betting
|
||||
well.
|
||||
well. Just like channel points, mana cannot be converted to real
|
||||
money.
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
|
@ -176,35 +177,47 @@ function TwitchChatCommands() {
|
|||
<Col className="gap-4">
|
||||
<Subtitle text="For Chat" />
|
||||
<Command
|
||||
command="bet yes #"
|
||||
desc="Bets an amount of M$ on yes, for example !bet yes 20"
|
||||
command="y#"
|
||||
desc="Bets # amount of M$ on yes, for example !y20 would bet M$20 on yes."
|
||||
/>
|
||||
<Command
|
||||
command="n#"
|
||||
desc="Bets # amount of M$ on no, for example !n30 would bet M$30 on no."
|
||||
/>
|
||||
<Command command="bet no #" desc="Bets an amount of M$ on no." />
|
||||
<Command
|
||||
command="sell"
|
||||
desc="Sells all shares you own. Using this command causes you to
|
||||
cash out early before the market resolves. This could be profitable
|
||||
(if the probability has moved towards the direction you bet) or cause
|
||||
a loss, although at least you keep some mana. For maximum profit (but
|
||||
also risk) it is better to not sell and wait for a favourable
|
||||
resolution."
|
||||
cash out early based on the current probability.
|
||||
Shares will always be worth the most if you wait for a favourable resolution. But, selling allows you to lower risk, or trade throughout the event which can maximise earnings."
|
||||
/>
|
||||
<Command command="balance" desc="Shows how much M$ you have." />
|
||||
<Command command="allin yes" desc="Bets your entire balance on yes." />
|
||||
<Command command="allin no" desc="Bets your entire balance on no." />
|
||||
<Command
|
||||
command="position"
|
||||
desc="Shows how many shares you own in the current market and what your fixed payout is."
|
||||
/>
|
||||
<Command command="balance" desc="Shows how much M$ your account has." />
|
||||
|
||||
<div className="mb-4" />
|
||||
|
||||
<Subtitle text="For Mods/Streamer" />
|
||||
|
||||
<div>
|
||||
We recommend streamers sharing the link to the control dock with their
|
||||
mods. Alternatively, chat commands can be used to control markets.{' '}
|
||||
</div>
|
||||
|
||||
<Command
|
||||
command="create <question>"
|
||||
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
|
||||
command="create [question]"
|
||||
desc="Creates and features a question. Be careful, this will replace any question that is currently featured."
|
||||
/>
|
||||
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
|
||||
<Command command="resolve no" desc="Resolves the market as 'No'." />
|
||||
<Command
|
||||
command="resolve n/a"
|
||||
desc="Resolves the market as 'N/A' and refunds everyone their mana."
|
||||
command="resolve na"
|
||||
desc="Cancels the market and refunds everyone their mana."
|
||||
/>
|
||||
<Command
|
||||
command="unfeature"
|
||||
desc="Unfeatures the market. The market will still be open on our site and available to be refeatured again. If you plan to never interact with a market again we recommend resolving to N/A and not this command."
|
||||
/>
|
||||
</Col>
|
||||
</div>
|
||||
|
@ -384,8 +397,8 @@ function SetUpBot(props: {
|
|||
buttonOnClick={copyOverlayLink}
|
||||
>
|
||||
Create a new browser source in your streaming software such as OBS.
|
||||
Paste in the above link and resize it to your liking. We recommend
|
||||
setting the size to 400x400.
|
||||
Paste in the above link and type in the desired size. We recommend
|
||||
450x375.
|
||||
</BotSetupStep>
|
||||
<BotSetupStep
|
||||
stepNum={3}
|
||||
|
@ -397,6 +410,10 @@ function SetUpBot(props: {
|
|||
your OBS as a custom dock.
|
||||
</BotSetupStep>
|
||||
</div>
|
||||
<div>
|
||||
Need help? Contact SirSalty#5770 in Discord or email
|
||||
david@manifold.markets
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user