diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index b27ac977..7c2153c1 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -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 diff --git a/common/contract.ts b/common/contract.ts index 2f71bab7..248c9745 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,6 +57,7 @@ export type Contract = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + dailyScore?: number followerCount?: number featuredOnHomeRank?: number likedByUserIds?: string[] diff --git a/common/group.ts b/common/group.ts index 871bc821..5220a1e8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -10,6 +10,7 @@ export type Group = { totalContracts: number totalMembers: number aboutPostId?: string + postIds: string[] chatDisabled?: boolean mostRecentContractAddedTime?: number cachedLeaderboard?: { diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index bf6f5ebc..48850dca 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -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 } }) diff --git a/common/user.ts b/common/user.ts index 0372d99b..b1365929 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { email?: string weeklyTrendingEmailSent?: boolean + weeklyPortfolioUpdateEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/common/util/time.ts b/common/util/time.ts index 9afb8db4..81dc3600 100644 --- a/common/util/time.ts +++ b/common/util/time.ts @@ -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)) diff --git a/docs/docs/api.md b/docs/docs/api.md index 64e26de8..007f6fa6 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -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. diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 7a30fed6..ed01f75c 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -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 @@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets. ## Art -- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) -- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) +- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) +- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) + +## 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 diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 01c4dc36..5c369e39 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -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? diff --git a/functions/package.json b/functions/package.json index ba59f090..0397c5db 100644 --- a/functions/package.json +++ b/functions/package.json @@ -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 } diff --git a/functions/src/close-market.ts b/functions/src/close-market.ts new file mode 100644 index 00000000..b8b252a7 --- /dev/null +++ b/functions/src/close-market.ts @@ -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() diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index cc05d817..911f3b8c 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -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 }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 9d00bb0b..76dc1298 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -61,6 +61,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { anyoneCanJoin, totalContracts: 0, totalMembers: memberIds.length, + postIds: [], } await groupRef.create(group) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 40d39bba..113a34bd 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -34,11 +34,12 @@ const contentSchema: z.ZodType = 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 } }) diff --git a/functions/src/email-templates/weekly-portfolio-update-no-movers.html b/functions/src/email-templates/weekly-portfolio-update-no-movers.html new file mode 100644 index 00000000..15303992 --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update-no-movers.html @@ -0,0 +1,411 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html new file mode 100644 index 00000000..fd99837f --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -0,0 +1,510 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+

+ + And here's some of the biggest changes in your portfolio: + +

+
+
+ + + + + + + + + + + + + + + + + + +
+ + {{question1Title}} + + + +

+ {{question1Prob}} + +

+ {{question1Change}} + +

+

+
+ + {{question2Title}} + + + +

+ {{question2Prob}} + +

+ {{question2Change}} + +

+

+
+ + {{question3Title}} + + + +

+ {{question3Prob}} + +

+ {{question3Change}} + +

+

+
+ + {{question4Title}} + + + +

+ {{question4Prob}} + +

+ {{question4Change}} + +

+

+
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 98309ebe..dd91789a 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -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 = { + 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 + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index adfee75e..9a8ec232 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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, } diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index 7878e410..21b52fbc 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -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 } ) diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flags.ts similarity index 87% rename from functions/src/reset-weekly-emails-flag.ts rename to functions/src/reset-weekly-emails-flags.ts index 947e3e88..1b2109a1 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flags.ts @@ -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, }) }) ) diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts index 57976ff2..497a4ba0 100644 --- a/functions/src/score-contracts.ts +++ b/functions/src/score-contracts.ts @@ -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 })) + } } } diff --git a/functions/src/scripts/contest/create-markets.ts b/functions/src/scripts/contest/create-markets.ts new file mode 100644 index 00000000..ba7245fe --- /dev/null +++ b/functions/src/scripts/contest/create-markets.ts @@ -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', + } +} diff --git a/functions/src/scripts/contest/criticism-and-red-teaming.ts b/functions/src/scripts/contest/criticism-and-red-teaming.ts new file mode 100644 index 00000000..0f19705a --- /dev/null +++ b/functions/src/scripts/contest/criticism-and-red-teaming.ts @@ -0,0 +1,1219 @@ +export const data = [ + // { + // "title": "Announcing a contest: EA Criticism and Red Teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming" + // }, + // { + // "title": "Pre-announcing a contest for critiques and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/Fx8pWSLKGwuqsfuRQ/pre-announcing-a-contest-for-critiques-and-red-teaming" + // }, + // { + // "title": "Resource for criticisms and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/uuQDgiJJaswEyyzan/resource-for-criticisms-and-red-teaming" + // }, + { + title: + 'Deworming and decay: replicating GiveWell’s cost-effectiveness analysis ', + author: 'JoelMcGuire', + link: 'https://forum.effectivealtruism.org/posts/MKiqGvijAXfcBHCYJ/deworming-and-decay-replicating-givewell-s-cost', + }, + { + title: 'Critiques of EA that I want to read', + author: 'abrahamrowe', + link: 'https://forum.effectivealtruism.org/posts/n3WwTz4dbktYwNQ2j/critiques-of-ea-that-i-want-to-read', + }, + { + title: 'My take on What We Owe the Future', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/9Y6Y6qoAigRC7A8eX/my-take-on-what-we-owe-the-future', + }, + { + title: + 'A Critical Review of Open Philanthropy’s Bet On Criminal Justice Reform', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/h2N9qEbvQ6RHABcae/a-critical-review-of-open-philanthropy-s-bet-on-criminal', + }, + { + title: 'Leaning into EA Disillusionment', + author: 'Helen', + link: 'https://forum.effectivealtruism.org/posts/MjTB4MvtedbLjgyja/leaning-into-ea-disillusionment', + }, + { + title: 'Red Teaming CEA’s Community Building Work', + author: 'AnonymousEAForumAccount', + link: 'https://forum.effectivealtruism.org/posts/hbejbRBpd6quqnTAB/red-teaming-cea-s-community-building-work-2', + }, + { + title: + 'A philosophical review of Open Philanthropy’s Cause Prioritisation Framework', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/bdiDW83SFAsoA4EeB/a-philosophical-review-of-open-philanthropy-s-cause', + }, + { + title: 'The Future Might Not Be So Great', + author: 'Jacy', + link: 'https://forum.effectivealtruism.org/posts/WebLP36BYDbMAKoa5/the-future-might-not-be-so-great', + }, + { + title: 'Potatoes: A Critical Review', + author: 'Pablo Villalobos', + link: 'https://forum.effectivealtruism.org/posts/iZrrWGvx2s2uPtica/potatoes-a-critical-review', + }, + { + title: 'Effective Altruism as Coordination & Field Incubation', + author: 'DavidNash', + link: 'https://forum.effectivealtruism.org/posts/Zm6iaaJhoZsoZ2uMD/effective-altruism-as-coordination-and-field-incubation', + }, + { + title: 'Enlightenment Values in a Vulnerable World', + author: 'Maxwell Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A4fMkKhBxio83NtBL/enlightenment-values-in-a-vulnerable-world', + }, + { + title: "21 criticisms of EA I'm thinking about", + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/X47rn28Xy5TRfGgSj/21-criticisms-of-ea-i-m-thinking-about', + }, + { + title: 'Longtermism and Computational Complexity', + author: 'David Kinney', + link: 'https://forum.effectivealtruism.org/posts/RRyHcupuDafFNXt6p/longtermism-and-computational-complexity', + }, + { + title: 'The Community Manifesto', + author: 'dianaqianmorgan', + link: 'https://forum.effectivealtruism.org/posts/cY3wBXoJoeHXJ7XYt/the-community-manifesto', + }, + { + title: 'Existential risk pessimism and the time of perils', + author: 'David Thorstad', + link: 'https://forum.effectivealtruism.org/posts/N6hcw8CxK7D3FCD5v/existential-risk-pessimism-and-the-time-of-perils-4', + }, + { + title: "A critical review of GiveWell's 2022 cost-effectiveness model", + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/6dtwkwBrHBGtc3xes/a-critical-review-of-givewell-s-2022-cost-effectiveness', + }, + { + title: 'How EA is perceived is crucial to its future trajectory', + author: 'GidonKadosh', + link: 'https://forum.effectivealtruism.org/posts/82ig8odF9ooccfJfa/how-ea-is-perceived-is-crucial-to-its-future-trajectory', + }, + { + title: + 'Before There Was Effective Altruism, There Was Effective Philanthropy', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/CdrKtaAX69iJuJD2r/before-there-was-effective-altruism-there-was-effective', + }, + { + title: + 'A concern about the “evolutionary anchor” of Ajeya Cotra’s report on AI timelines.', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/FHTyixYNnGaQfEexH/a-concern-about-the-evolutionary-anchor-of-ajeya-cotra-s', + }, + { + title: + 'EA is becoming increasingly inaccessible, at the worst possible time', + author: 'Ann Garth', + link: 'https://forum.effectivealtruism.org/posts/duPDKhtXTJNAJBaSf/ea-is-becoming-increasingly-inaccessible-at-the-worst', + }, + { + title: 'Red-teaming contest: demographics and power structures in EA', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/oD3zus6LhbhBj6z2F/red-teaming-contest-demographics-and-power-structures-in-ea', + }, + { + title: 'The Nietzschean Challenge to Effective Altruism', + author: 'Richard Y Chappell', + link: 'https://forum.effectivealtruism.org/posts/bedstSbqaP8aDBfDr/the-nietzschean-challenge-to-effective-altruism', + }, + { + title: + 'The Case for Funding New Long-Term Randomized Controlled Trials of Deworming', + author: 'MHR', + link: 'https://forum.effectivealtruism.org/posts/CyxZmwQ7gADwjBHG6/the-case-for-funding-new-long-term-randomized-controlled', + }, + { + title: 'Population Ethics Without Axiology: A Framework', + author: 'Lukas_Gloor', + link: 'https://forum.effectivealtruism.org/posts/dQvDxDMyueLyydHw4/population-ethics-without-axiology-a-framework', + }, + { + title: 'Questioning the Value of Extinction Risk Reduction', + author: 'Red Team 8', + link: 'https://forum.effectivealtruism.org/posts/eeDsHDoM9De4iGGLw/questioning-the-value-of-extinction-risk-reduction-1', + }, + { + title: 'Red teaming introductory EA courses', + author: 'Philip Hall Andersen', + link: 'https://forum.effectivealtruism.org/posts/JDEDsCaQd2CYm7QEi/red-teaming-introductory-ea-courses', + }, + { + title: 'Systemic Cascading Risks: Relevance in Longtermism & Value Lock-In', + author: 'Richard Ren', + link: 'https://forum.effectivealtruism.org/posts/mWGodAi9Mv2a2EbNj/systemic-cascading-risks-relevance-in-longtermism-and-value', + }, + { + title: + 'community building solely as a tool for impact creates toxic communities', + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/EpMQBmQv7e4yaDYYN/community-building-solely-as-a-tool-for-impact-creates-toxic', + }, + { + title: + 'Are you really in a race? The Cautionary Tales of Szilárd and Ellsberg', + author: 'HaydnBelfield', + link: 'https://forum.effectivealtruism.org/posts/cXBznkfoPJAjacFoT/are-you-really-in-a-race-the-cautionary-tales-of-szilard-and', + }, + { + title: + "Quantifying Uncertainty in GiveWell's GiveDirectly Cost-Effectiveness Analysis", + author: 'Hazelfire', + link: 'https://forum.effectivealtruism.org/posts/ycLhq4Bmep8ssr4wR/quantifying-uncertainty-in-givewell-s-givedirectly-cost', + }, + { + title: 'Changing the world through slack & hobbies', + author: 'Steven Byrnes', + link: 'https://forum.effectivealtruism.org/posts/ZkhABk4rRMqsNmwvf/changing-the-world-through-slack-and-hobbies', + }, + { + title: + 'Some concerns about policy work funding and the Long Term Future Fund', + author: 'weeatquince', + link: 'https://forum.effectivealtruism.org/posts/Xfon9oxyMFv47kFnc/some-concerns-about-policy-work-funding-and-the-long-term', + }, + { + title: + 'Why Effective Altruists Should Put a Higher Priority on Funding Academic Research', + author: 'Stuart Buck', + link: 'https://forum.effectivealtruism.org/posts/uTQKFNXrMXuwGe4vw/why-effective-altruists-should-put-a-higher-priority-on', + }, + { + title: 'Remuneration In Effective Altruism', + author: 'Stefan_Schubert', + link: 'https://forum.effectivealtruism.org/posts/wWnRtjDiyjRRgaFDb/remuneration-in-effective-altruism', + }, + { + title: "You Don't Need To Justify Everything", + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/HX9ZDGwwSxAab46N9/you-don-t-need-to-justify-everything', + }, + { + title: 'EAs underestimate uncertainty in cause prioritisation', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/Ekd3oATEZkBbJ95uD/eas-underestimate-uncertainty-in-cause-prioritisation', + }, + { + title: '"Doing Good Best" isn\'t the EA ideal', + author: 'Davidmanheim', + link: 'https://forum.effectivealtruism.org/posts/f9NpDx65zY6Qk9ofe/doing-good-best-isn-t-the-ea-ideal', + }, + { + title: 'The discount rate is not zero', + author: 'Thomaaas', + link: 'https://forum.effectivealtruism.org/posts/zLZMsthcqfmv5J6Ev/the-discount-rate-is-not-zero', + }, + { + title: 'Questioning the Foundations of EA', + author: 'Wei_Dai', + link: 'https://forum.effectivealtruism.org/posts/zvNwSG2Xvy8x5Rtba/questioning-the-foundations-of-ea', + }, + { + title: + 'Notes on how prizes may fail and how to reduce the risk of them failing', + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/h2WcJf7pg5Qdfhsm3/notes-on-how-prizes-may-fail-and-how-to-reduce-the-risk-of', + }, + { + title: 'EA Culture and Causes: Less is More', + author: 'Allen Bell', + link: 'https://forum.effectivealtruism.org/posts/FWHDX32ecr9aF4xKw/ea-culture-and-causes-less-is-more', + }, + { + title: 'Things usually end slowly', + author: 'OllieBase', + link: 'https://forum.effectivealtruism.org/posts/qLwtCuh6nDCsrsrMK/things-usually-end-slowly', + }, + { + title: + 'Doing good is a privilege. This needs to change if we want to do good long-term. ', + author: 'SofiaBalderson', + link: 'https://forum.effectivealtruism.org/posts/gicYG5ymk4pPzrKAd/doing-good-is-a-privilege-this-needs-to-change-if-we-want-to', + }, + { + title: 'Animal Zoo', + author: 'bericlair', + link: 'https://forum.effectivealtruism.org/posts/YfmdnkmnoWBhmBaQL/animal-zoo', + }, + { + title: 'Summaries are underrated', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/nDawZHxDR3j53zdbf/summaries-are-underrated', + }, + { + title: 'Longtermism, risk, and extinction', + author: 'Richard Pettigrew', + link: 'https://forum.effectivealtruism.org/posts/xAoZotkzcY5mvmXFY/longtermism-risk-and-extinction', + }, + { + title: + 'Prioritisation should consider potential for ongoing evaluation alongside expected value and evidence quality', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/orfgdYRZXNKQtqzmh/prioritisation-should-consider-potential-for-ongoing', + }, + { + title: + '“Existential Risk” is badly named and leads to narrow focus on astronomical waste', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/qFdifovCmckujxEsq/existential-risk-is-badly-named-and-leads-to-narrow-focus-on', + }, + { + title: + 'The great energy descent (short version) - An important thing EA might have missed', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/wXzc75txE5hbHqYug/the-great-energy-descent-short-version-an-important-thing-ea', + }, + { + title: 'The Long Reflection as the Great Stagnation ', + author: 'Larks', + link: 'https://forum.effectivealtruism.org/posts/o5Q8dXfnHTozW9jkY/the-long-reflection-as-the-great-stagnation', + }, + { + title: 'Community posts: The Forum needs a way to work in public', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/NxWssGagWoQWErRer/community-posts-the-forum-needs-a-way-to-work-in-public', + }, + { + title: 'Improving Karma: $8mn of possible value (my estimate)', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/YajssmjwKndBTahQx/improving-karma-usd8mn-of-possible-value-my-estimate', + }, + { + title: 'Leveraging labor shortages as a pathway to career impact', + author: 'IanDavidMoss', + link: 'https://forum.effectivealtruism.org/posts/xdMn6FeQGjrXDPnQj/leveraging-labor-shortages-as-a-pathway-to-career-impact', + }, + { + title: 'How to dissolve moral cluelessness about donating mosquito nets', + author: 'ben.smith', + link: 'https://forum.effectivealtruism.org/posts/9XgLq4eQHMWybDsrv/how-to-dissolve-moral-cluelessness-about-donating-mosquito-1', + }, + { + title: '[TikTok] Comparability between suffering and happiness', + author: 'Ben_West', + link: 'https://forum.effectivealtruism.org/posts/ASmtarf3ADYb2Xmrt/tiktok-comparability-between-suffering-and-happiness', + }, + { + title: + 'Red teaming a model for estimating the value of longtermist interventions - A critique of Tarsney\'s "The Epistemic Challenge to Longtermism"', + author: 'Anjay F', + link: 'https://forum.effectivealtruism.org/posts/u9CvMCCmQRgjBD828/red-teaming-a-model-for-estimating-the-value-of-longtermist', + }, + { + title: + 'Criticism of EA Criticisms: Is the real disagreement about cause prio?', + author: 'Akash', + link: 'https://forum.effectivealtruism.org/posts/qgQaWub8iR2EERq7i/criticism-of-ea-criticisms-is-the-real-disagreement-about', + }, + { + title: 'Effective Altruism Should Seek Less Criticism', + author: 'The Chaostician', + link: 'https://forum.effectivealtruism.org/posts/oQA7Z6JKHAwvWz9wk/effective-altruism-should-seek-less-criticism', + }, + { + title: + 'The great energy descent - Part 1: Can renewables replace fossil fuels?', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/qG8k5pzhaDk6FhcYv/the-great-energy-descent-part-1-can-renewables-replace', + }, + { + title: 'We’re searching for meaning, not happiness (et al.)', + author: 'Joshua Clingo', + link: 'https://forum.effectivealtruism.org/posts/gmTYoTmggojK5bywA/we-re-searching-for-meaning-not-happiness-et-al', + }, + { + title: + 'Earning to give should have focused more on “entrepreneurship to give”', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/JXDi8tL6uoKPhg4uw/earning-to-give-should-have-focused-more-on-entrepreneurship', + }, + { + title: 'Longtermism neglects anti-ageing research', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/pbPmhWhGxsGSzwpNE/longtermism-neglects-anti-ageing-research', + }, + { + title: + 'Popular EA Authors Should Give Libraries More Copies of Their EBooks', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AAL2zPtg7T6bjWijT/popular-ea-authors-should-give-libraries-more-copies-of', + }, + { + title: 'this one weird trick creates infinite utility', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/BwuLaA97BAtLcbuuF/this-one-weird-trick-creates-infinite-utility', + }, + { + title: 'Think again: Should EA be a social movement?', + author: 'An A', + link: 'https://forum.effectivealtruism.org/posts/giznESHxt45SvuhZw/think-again-should-ea-be-a-social-movement', + }, + { + title: 'Rethinking longtermism and global development', + author: 'BrownHairedEevee', + link: 'https://forum.effectivealtruism.org/posts/GAEjbu2eHRXPHwTxF/rethinking-longtermism-and-global-development', + }, + { + title: 'Hiring: The Ignored Resource of Rejected EA Job Candidates', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/ekLyLdiCCcD6BqbJR/hiring-the-ignored-resource-of-rejected-ea-job-candidates-1', + }, + { + title: 'Suggestions for 80,000K ', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/MdvGwL4hTM2B96x4d/suggestions-for-80-000k', + }, + { + title: "We're funding task-adjusted survival (DALYs)", + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/Gs8HS8QFBhtgAa8qo/we-re-funding-task-adjusted-survival-dalys', + }, + { + title: + 'A Need for Final Chapter Revision in Intro EA Fellowship Curricula & Other Ways to Fix Holes in The Funnel', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AaXDbHZhJYgxLCjYZ/a-need-for-final-chapter-revision-in-intro-ea-fellowship', + }, + { + title: 'Moral Injury, Mental Health, and Obsession in EA', + author: 'ECJ', + link: 'https://forum.effectivealtruism.org/posts/jiiyBcoZXXXT7eFHm/moral-injury-mental-health-and-obsession-in-ea', + }, + { + title: 'Are we already past the precipice?', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/e6prpQSojPW3jC7YD/are-we-already-past-the-precipice', + }, + { + title: 'EA has a lying problem [Link Post]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/8dWms5YxYwZW9xneL/ea-has-a-lying-problem-link-post', + }, + { + title: + "Senior EA 'ops' roles: if you want to undo the bottleneck, hire differently", + author: 'AnonymousThrowAway', + link: 'https://forum.effectivealtruism.org/posts/X8YMxbWNsF5FNaCFz/senior-ea-ops-roles-if-you-want-to-undo-the-bottleneck-hire', + }, + { + title: "On Deference and Yudkowsky's AI Risk Estimates", + author: 'Ben Garfinkel', + link: 'https://forum.effectivealtruism.org/posts/NBgpPaz5vYe3tH4ga/on-deference-and-yudkowsky-s-ai-risk-estimates', + }, + { + title: 'EA is too reliant on personal connections', + author: 'sawyer', + link: 'https://forum.effectivealtruism.org/posts/dvcpKuajunxdaZ6se/ea-is-too-reliant-on-personal-connections', + }, + { + title: 'Michael Nielsen\'s "Notes on effective altruism"', + author: 'Pablo', + link: 'https://forum.effectivealtruism.org/posts/JBAPssaYMMRfNqYt7/michael-nielsen-s-notes-on-effective-altruism', + }, + { + title: 'Effective altruism in the garden of ends', + author: 'tyleralterman', + link: 'https://forum.effectivealtruism.org/posts/AjxqsDmhGiW9g8ju6/effective-altruism-in-the-garden-of-ends', + }, + { + title: 'Critique of MacAskill’s “Is It Good to Make Happy People?”', + author: 'Magnus Vinding', + link: 'https://forum.effectivealtruism.org/posts/vZ4kB8gpvkfHLfz8d/critique-of-macaskill-s-is-it-good-to-make-happy-people', + }, + { + title: 'Effective altruism is no longer the right name for the movement', + author: 'ParthThaya', + link: 'https://forum.effectivealtruism.org/posts/2FB8tK9da89qksZ9E/effective-altruism-is-no-longer-the-right-name-for-the-1', + }, + { + title: 'Prioritizing x-risks may require caring about future people', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/rvvwCcixmEep4RSjg/prioritizing-x-risks-may-require-caring-about-future-people', + }, + { + title: 'Ways money can make things worse', + author: 'Jan_Kulveit', + link: 'https://forum.effectivealtruism.org/posts/YKEPXLQhYjm3nP7Td/ways-money-can-make-things-worse', + }, + { + title: "EA Shouldn't Try to Exercise Direct Political Power", + author: 'iamasockpuppet', + link: 'https://forum.effectivealtruism.org/posts/BgNnctp6deoGdKtbr/ea-shouldn-t-try-to-exercise-direct-political-power', + }, + { + title: 'EA on nuclear war and expertise', + author: 'bean', + link: 'https://forum.effectivealtruism.org/posts/bCB88GKeXTaxozr6y/ea-on-nuclear-war-and-expertise', + }, + { + title: 'The most important climate change uncertainty', + author: 'cwa', + link: 'https://forum.effectivealtruism.org/posts/nBN6NENeudd2uJBCQ/the-most-important-climate-change-uncertainty', + }, + { + title: "Critique of OpenPhil's macroeconomic policy advocacy", + author: 'Hauke Hillebrandt', + link: 'https://forum.effectivealtruism.org/posts/cDdcNzyizzdZD4hbR/critique-of-openphil-s-macroeconomic-policy-advocacy', + }, + { + title: + 'Methods for improving uncertainty analysis in EA cost-effectiveness models', + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/CuuCGzuzwD6cdu9mo/methods-for-improving-uncertainty-analysis-in-ea-cost', + }, + { + title: + 'Did OpenPhil ever publish their in-depth review of their three-year OpenAI grant?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/sZhhW2AECqT5JikdE/did-openphil-ever-publish-their-in-depth-review-of-their', + }, + { + title: 'Go Republican, Young EA!', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/myympkZ6SuT59vuEQ/go-republican-young-ea', + }, + { + title: + 'Are too many young, highly-engaged longtermist EAs doing movement-building?', + author: 'Anonymous_EA', + link: 'https://forum.effectivealtruism.org/posts/Lfy89vKqHatQdJgDZ/are-too-many-young-highly-engaged-longtermist-eas-doing', + }, + { + title: "EA's Culture and Thinking are Severely Limiting its Impact", + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/jhCGX8Gwq44TmyPJv/ea-s-culture-and-thinking-are-severely-limiting-its-impact', + }, + { + title: 'Criticism of EA Criticism Contest', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/qjMPATBLM5p4ABcEB/criticism-of-ea-criticism-contest', + }, + { + title: + 'The EA community might be neglecting the value of influencing people', + author: 'JulianHazell', + link: 'https://forum.effectivealtruism.org/posts/3szWd8HwWccJb9z5L/the-ea-community-might-be-neglecting-the-value-of', + }, + { + title: 'Slowing down AI progress is an underexplored alignment strategy', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/6LNvQYyNQpDQmnnux/slowing-down-ai-progress-is-an-underexplored-alignment', + }, + { + title: 'Some core assumptions of effective altruism, according to me', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/av7MiEhi983SjoXTe/some-core-assumptions-of-effective-altruism-according-to-me', + }, + { + title: 'Transcript of Twitter Discussion on EA from June 2022', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/MpJcvzHfQyFLxLZNh/transcript-of-twitter-discussion-on-ea-from-june-2022', + }, + { + title: 'EA culture is special; we should proceed with intentionality', + author: 'James Lin', + link: 'https://forum.effectivealtruism.org/posts/KuKzqhxLzaREL7KKi/ea-culture-is-special-we-should-proceed-with-intentionality', + }, + { + title: 'Four Concerns Regarding Longtermism', + author: 'Pat Andriola', + link: 'https://forum.effectivealtruism.org/posts/ESzGcWfkMtJgF2CCA/four-concerns-regarding-longtermism', + }, + { + title: 'Chesterton Fences and EA’s X-risks', + author: 'jehan', + link: 'https://forum.effectivealtruism.org/posts/j4RnXAQgyMCSLzBkW/chesterton-fences-and-ea-s-x-risks', + }, + { + title: 'Introduction to Pragmatic AI Safety [Pragmatic AI Safety #1]', + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/MskKEsj8nWREoMjQK/introduction-to-pragmatic-ai-safety-pragmatic-ai-safety-1', + }, + { + title: 'EA needs to understand its “failures” better', + author: 'mariushobbhahn', + link: 'https://forum.effectivealtruism.org/posts/Nwut6L6eAGmrFSaT4/ea-needs-to-understand-its-failures-better', + }, + { + title: 'An Evaluation of Animal Charity Evaluators ', + author: 'eaanonymous1234', + link: 'https://forum.effectivealtruism.org/posts/pfSiMpkmskRB4WxYW/an-evaluation-of-animal-charity-evaluators', + }, + { + title: 'What is the overhead of grantmaking?', + author: 'MathiasKB', + link: 'https://forum.effectivealtruism.org/posts/RXm2mxvq3ReXmsHm4/what-is-the-overhead-of-grantmaking', + }, + { + title: + 'A Critique of The Precipice: Chapter 6 - The Risk Landscape [Red Team Challenge]', + author: 'Sarah Weiler', + link: 'https://forum.effectivealtruism.org/posts/faW24r7ocbcPisgCH/a-critique-of-the-precipice-chapter-6-the-risk-landscape-red', + }, + { + title: + 'Wheeling and dealing: An internal bargaining approach to moral uncertainty', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/kxEAkcEvyiwmjirjN/wheeling-and-dealing-an-internal-bargaining-approach-to', + }, + { + title: 'Let’s not glorify people for how they look.', + author: 'Florence', + link: 'https://forum.effectivealtruism.org/posts/8ii5SD7HBL4EdYw5K/let-s-not-glorify-people-for-how-they-look-2', + }, + { + title: 'The first AGI will be a buggy mess', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/pXjpZep49M6GGxFQF/the-first-agi-will-be-a-buggy-mess', + }, + { + title: '[Cause Exploration Prizes] The importance of Intercausal Impacts', + author: 'Sebastian Joy 樂百善', + link: 'https://forum.effectivealtruism.org/posts/MayveXrHbvXMBRo78/cause-exploration-prizes-the-importance-of-intercausal', + }, + { + title: 'The Windfall Clause has a remedies problem', + author: 'John Bridge', + link: 'https://forum.effectivealtruism.org/posts/wBzfLyfJFfocmdrwL/the-windfall-clause-has-a-remedies-problem', + }, + { + title: 'Future Paths for Effective Altruism', + author: 'James Broughel', + link: 'https://forum.effectivealtruism.org/posts/yzAoHcTzf3AjeGYsP/future-paths-for-effective-altruism', + }, + { + title: 'The Effective Altruism culture', + author: 'PabloAMC', + link: 'https://forum.effectivealtruism.org/posts/NkF9rAjZpkDajqDDt/the-effective-altruism-culture', + }, + { + title: + 'The Role of Individual Consumption Decisions in Animal Welfare and Climate are Analogous', + author: 'Gabriel Weil', + link: 'https://forum.effectivealtruism.org/posts/HWpwfTF5M84jo4iyo/the-role-of-individual-consumption-decisions-in-animal', + }, + { + title: 'Criticism of the main framework in AI alignment', + author: 'Michele Campolo', + link: 'https://forum.effectivealtruism.org/posts/Cs8qhNakLuLXY4GvE/criticism-of-the-main-framework-in-ai-alignment', + }, + { + title: 'Crowdsourced Criticisms: What does EA think about EA?', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/jK2Qends7GnyaRhm2/crowdsourced-criticisms-what-does-ea-think-about-ea', + }, + { + title: + 'EAs should recommend cost-effective interventions in more cause areas (not just the most pressing ones) \n\n', + author: 'Amber Dawn', + link: 'https://forum.effectivealtruism.org/posts/JiEyCNoGD3WwTgDkG/eas-should-recommend-cost-effective-interventions-in-more', + }, + { + title: + 'AGI Battle Royale: Why “slow takeover” scenarios devolve into a chaotic multi-AGI fight to the death', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/TxrzhfRr6EXiZHv4G/agi-battle-royale-why-slow-takeover-scenarios-devolve-into-a', + }, + { + title: 'Effective means to combat autocracies', + author: 'Junius Brutus', + link: 'https://forum.effectivealtruism.org/posts/kawE7rFmp3SkzLxpx/effective-means-to-combat-autocracies', + }, + { + title: 'Editing wild animals is underexplored in What We Owe the Future', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/cWnQMagKFqJoaGA5M/editing-wild-animals-is-underexplored-in-what-we-owe-the', + }, + { + title: 'Reasons for my negative feelings towards the AI risk discussion', + author: 'fergusq', + link: 'https://forum.effectivealtruism.org/posts/hLbWWuDr3EbeQqrmg/reasons-for-my-negative-feelings-towards-the-ai-risk', + }, + { + title: + 'We need more discussion and clarity on how university groups create value', + author: 'Oscar Galvin', + link: 'https://forum.effectivealtruism.org/posts/HNHHNCDLEsDNjNwvm/we-need-more-discussion-and-clarity-on-how-university-groups', + }, + { + title: 'What 80000 Hours gets wrong about solar geoengineering', + author: 'Gideon Futerman', + link: 'https://forum.effectivealtruism.org/posts/6dbET4f9LbJZZTuDW/what-80000-hours-gets-wrong-about-solar-geoengineering', + }, + { + title: + 'Concerns/Thoughts over international aid, longtermism and philosophical notes on speaking with Larry Temkin.', + author: 'Ben Yeoh', + link: 'https://forum.effectivealtruism.org/posts/uhaKXdkAcuXJZHSci/concerns-thoughts-over-international-aid-longtermism-and', + }, + { + title: 'On longtermism, Bayesianism, and the doomsday argument', + author: 'iporphyry', + link: 'https://forum.effectivealtruism.org/posts/f2RzSd2ukFZyNB86L/on-longtermism-bayesianism-and-the-doomsday-argument', + }, + { + title: 'Friendship is Optimal: EAGs should be online', + author: 'Emrik', + link: 'https://forum.effectivealtruism.org/posts/35nRwEyzCKDfh3dCr/friendship-is-optimal-eags-should-be-online', + }, + { + title: 'A Critique of AI Takeover Scenarios', + author: 'Fods12', + link: 'https://forum.effectivealtruism.org/posts/j7X8nQ7YvvA7Pi4BX/a-critique-of-ai-takeover-scenarios', + }, + { + title: 'The dangers of high salaries within EA organisations', + author: 'James Ozden', + link: 'https://forum.effectivealtruism.org/posts/WXD3bRDBkcBhJ5Wcr/the-dangers-of-high-salaries-within-ea-organisations', + }, + { + title: 'Low-key Longtermism', + author: 'Jonathan Rystrom', + link: 'https://forum.effectivealtruism.org/posts/BaynHfmkjrfL8DXcK/low-key-longtermism', + }, + { + title: + 'The Credibility of Apocalyptic Claims: A Critique of Techno-Futurism within Existential Risk', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/a2XaDeadFe6eHfDwG/the-credibility-of-apocalyptic-claims-a-critique-of-techno', + }, + { + title: + 'The Role of "Economism" in the Belief-Formation Systems of Effective Altruism', + author: 'Thomas Aitken', + link: 'https://forum.effectivealtruism.org/posts/cR4pCrATD5SSN35Sm/the-role-of-economism-in-the-belief-formation-systems-of', + }, + { + title: + 'A slightly (I think?) different slant on why EA elitism bias/top-university focus/lack of diversity is a problem', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/LCfQCvtFyAEnxCnMf/a-slightly-i-think-different-slant-on-why-ea-elitism-bias', + }, + { + title: 'Chaining the evil genie: why "outer" AI safety is probably easy', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/AoPR8BFrAFgGGN9iZ/chaining-the-evil-genie-why-outer-ai-safety-is-probably-easy', + }, + { + title: 'Should EA shift away (a bit) from elite universities?', + author: 'Joseph Lemien', + link: 'https://forum.effectivealtruism.org/posts/Rts8vKvbxkngPbFh7/should-ea-shift-away-a-bit-from-elite-universities', + }, + { + title: 'Aesthetics as Epistemic Humility', + author: 'Étienne Fortier-Dubois', + link: 'https://forum.effectivealtruism.org/posts/bo6Jsvmq9oiykbDrM/aesthetics-as-epistemic-humility', + }, + { + title: + "What if states don't listen? A fundamental gap in x-risk reduction strategies ", + author: 'HTC', + link: 'https://forum.effectivealtruism.org/posts/sFxtu6ZKAScDSqLrK/what-if-states-don-t-listen-a-fundamental-gap-in-x-risk', + }, + { + title: 'Eliminate or Adjust Strong Upvotes to Improve the Forum', + author: 'Afternoon Coffee', + link: 'https://forum.effectivealtruism.org/posts/2XGFdBkxa5Hm5LWZq/eliminate-or-adjust-strong-upvotes-to-improve-the-forum', + }, + { + title: 'On the Philosophical Foundations of EA', + author: 'mm6', + link: 'https://forum.effectivealtruism.org/posts/gLWmeKTe68ZHnomwy/on-the-philosophical-foundations-of-ea', + }, + { + title: + "Why I Hope (Certain) Hedonic Utilitarians Don't Control the Long-term Future", + author: 'Jared_Riggs', + link: 'https://forum.effectivealtruism.org/posts/PJKecg5ugYnyWhezC/why-i-hope-certain-hedonic-utilitarians-don-t-control-the', + }, + { + title: 'Be More Succinct', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/eNa8GpEi5HX94CZ2n/be-more-succinct', + }, + { + title: 'Against Anthropic Shadow', + author: 'tobycrisford', + link: 'https://forum.effectivealtruism.org/posts/A47EWTS6oBKLqxBpw/against-anthropic-shadow', + }, + { + title: + 'Ideological tensions between Effective Altruism and The UK Civil Service', + author: 'KZ X', + link: 'https://forum.effectivealtruism.org/posts/J7cAFqq9g9LzSe5E3/ideological-tensions-between-effective-altruism-and-the-uk', + }, + { + title: 'We’re really bad at guessing the future', + author: 'Benj Azose', + link: 'https://forum.effectivealtruism.org/posts/DkmNPpqTJKmudBHnp/we-re-really-bad-at-guessing-the-future', + }, + { + title: + 'Effective Altruism, the Principle of Explosion and Epistemic Fragility', + author: 'Eigengender', + link: 'https://forum.effectivealtruism.org/posts/zG4pnJBCMi5t49Eya/effective-altruism-the-principle-of-explosion-and-epistemic', + }, + { + title: + 'Should EA influence governments to enact more effective interventions?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/pGPcfjxazPGFJyYHW/should-ea-influence-governments-to-enact-more-effective', + }, + { + title: 'Should large EA nonprofits consider splitting?', + author: 'Arepo', + link: 'https://forum.effectivealtruism.org/posts/J3pZ7fY6yvypvJrJE/should-large-ea-nonprofits-consider-splitting', + }, + { + title: + "Evaluating large-scale movement building: A better way to critique Open Philanthropy's criminal justice reform", + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/7ajePuRKiCo7fA92B/evaluating-large-scale-movement-building-a-better-way-to', + }, + { + title: 'Evaluation of Longtermist Institutional Reform', + author: 'Dwarkesh Patel', + link: 'https://forum.effectivealtruism.org/posts/v4Z6phNcDsdXtzj2K/evaluation-of-longtermist-institutional-reform', + }, + { + title: 'A Quick List of Some Problems in AI Alignment As A Field', + author: 'NicholasKross', + link: 'https://forum.effectivealtruism.org/posts/JFmhYuso5s9PgrQET/a-quick-list-of-some-problems-in-ai-alignment-as-a-field', + }, + { + title: 'Fixing bad incentives in EA', + author: 'IncentivesAccount', + link: 'https://forum.effectivealtruism.org/posts/3PrTiXhhNBdGtR9qf/fixing-bad-incentives-in-ea', + }, + { + title: 'The danger of good stories', + author: 'DuncanS', + link: 'https://forum.effectivealtruism.org/posts/eZK95zxwpzNySRebC/the-danger-of-good-stories', + }, + { + title: 'A dilemma for Maximize Expected Choiceworthiness (MEC)', + author: 'Calvin_Baker', + link: 'https://forum.effectivealtruism.org/posts/Gk7NhzFy2hHFdFTYr/a-dilemma-for-maximize-expected-choiceworthiness-mec', + }, + { + title: 'Should you still use the ITN framework? [Red Teaming Contest]', + author: 'frib', + link: 'https://forum.effectivealtruism.org/posts/hjH94Ji4CrpKadoCi/should-you-still-use-the-itn-framework-red-teaming-contest', + }, + { + title: 'Proposed tweak to the longtermism pitch', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/nAvpSXELT2FZMD9aA/proposed-tweak-to-the-longtermism-pitch', + }, + { + title: 'EA vs. FIRE – reconciling these two movements', + author: 'Stewed_Walrus', + link: 'https://forum.effectivealtruism.org/posts/j2ccaxmHcjiwGDs9T/ea-vs-fire-reconciling-these-two-movements', + }, + { + title: 'Should young EAs really focus on career capital?', + author: 'Michael B.', + link: 'https://forum.effectivealtruism.org/posts/RYBFyDWAYZL4YCkW2/should-young-eas-really-focus-on-career-capital', + }, + { + title: 'Prediction Markets are Somewhat Overrated Within EA', + author: 'Francis', + link: 'https://forum.effectivealtruism.org/posts/4LsrNczpF6mfrHP4M/prediction-markets-are-somewhat-overrated-within-ea', + }, + { + title: 'Capitalism, power and epistemology: a critique of EA', + author: 'Matthew_Doran', + link: 'https://forum.effectivealtruism.org/posts/xWFhD6uQuZehrDKeY/capitalism-power-and-epistemology-a-critique-of-ea', + }, + { + title: 'EA Worries and Criticism ', + author: 'Connor Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A5tRZC2mduJfpMhud/ea-worries-and-criticism', + }, + { + title: 'EA criticism contest: Why I am not an effective altruist', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/PZ6pEaNkzAg62ze69/ea-criticism-contest-why-i-am-not-an-effective-altruist', + }, + { + title: 'Nuclear Fine-Tuning: How Many Worlds Have Been Destroyed?', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/Gg2YsjGe3oahw2kxE/nuclear-fine-tuning-how-many-worlds-have-been-destroyed', + }, + { + title: 'An epistemic critique of longtermism', + author: 'Nathan_Barnard', + link: 'https://forum.effectivealtruism.org/posts/2455tgtiBsm5KXBfv/an-epistemic-critique-of-longtermism', + }, + { + title: 'Red Team: Write More.', + author: 'Weaver', + link: 'https://forum.effectivealtruism.org/posts/5A5cMh223b9s4uHwE/red-team-write-more', + }, + { + title: 'End-To-End Encryption For EA', + author: 'Talking Tree', + link: 'https://forum.effectivealtruism.org/posts/tekdQKdfFe3YJTwML/end-to-end-encryption-for-ea', + }, + { + title: 'Effective Altruism is Unkind', + author: 'Oliver Scott Curry', + link: 'https://forum.effectivealtruism.org/posts/cC6tGHctzrMmEAH8j/effective-altruism-is-unkind', + }, + { + title: 'Towards a more ecumenical EA movement ', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/NR2Y2B8Y4Wxn8pAS8/towards-a-more-ecumenical-ea-movement', + }, + { + title: + 'Effective altruism is similar to the AI alignment problem and suffers from the same difficulties [Criticism and Red Teaming Contest entry]', + author: 'turchin', + link: 'https://forum.effectivealtruism.org/posts/g8fn7oyvki4psJeYR/effective-altruism-is-similar-to-the-ai-alignment-problem', + }, + { + title: + 'The great energy descent - Part 2: Limits to growth and why we probably won’t reach the stars', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/8sW4h368DsoooHBNP/the-great-energy-descent-part-2-limits-to-growth-and-why-we', + }, + { + title: 'What a Large and Welcoming EA Could Accomplish', + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/K24widt85ZbGqzZKN/what-a-large-and-welcoming-ea-could-accomplish', + }, + { + title: 'Should we call ourselves effective altruists?', + author: 'Sam_Coggins', + link: 'https://forum.effectivealtruism.org/posts/YyDRSARnXz8r5dgca/should-we-call-ourselves-effective-altruists', + }, + { + title: 'Compounding assumptions and what it mean to be altruistic', + author: 'Badger', + link: 'https://forum.effectivealtruism.org/posts/4RGuqDxui2xWkXnda/compounding-assumptions-and-what-it-mean-to-be-altruistic', + }, + { + title: + 'The great energy descent - Post 3: What we can do, what we can’t do', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/9zTLPy3zqJ7YfS7kn/the-great-energy-descent-post-3-what-we-can-do-what-we-can-t', + }, + { + title: 'Enantiodromia', + author: 'ChristianKleineidam', + link: 'https://forum.effectivealtruism.org/posts/b4ASDM434qh3rxLki/enantiodromia', + }, + { + title: 'Deontology, the Paralysis Argument and altruistic longtermism', + author: "William D'Alessandro", + link: 'https://forum.effectivealtruism.org/posts/DKe5eQhJoLNMWgaQv/deontology-the-paralysis-argument-and-altruistic-longtermism', + }, + { + title: 'Path dependence and its impact on long-term outcomes', + author: 'Archanaa', + link: 'https://forum.effectivealtruism.org/posts/jadS8deYknecGSebp/path-dependence-and-its-impact-on-long-term-outcomes', + }, + { + title: 'Histories of Value Lock-in and Ideology Critique', + author: 'clem', + link: 'https://forum.effectivealtruism.org/posts/poWd3CcGeQPas3Zbo/histories-of-value-lock-in-and-ideology-critique', + }, + { + title: 'A Case Against Strong Longtermism', + author: 'A. Wolff', + link: 'https://forum.effectivealtruism.org/posts/LADQ6dTGsQ2BBMrBv/a-case-against-strong-longtermism-1', + }, + { + title: 'The totalitarian implications of Effective Altruism', + author: 'Ed_Talks', + link: 'https://forum.effectivealtruism.org/posts/guyuidDdxNNxFegbJ/the-totalitarian-implications-of-effective-altruism-1', + }, + { + title: 'Forecasting Through Fiction', + author: 'Yitz', + link: 'https://forum.effectivealtruism.org/posts/DhJhtxMX6SdYAsWiY/forecasting-through-fiction', + }, + { + title: 'EA Undervalues Unseen Data', + author: 'tcelferact', + link: 'https://forum.effectivealtruism.org/posts/MpYPCq9dW8wovYpRY/ea-undervalues-unseen-data', + }, + { + title: 'The Happiness Maximizer:\nWhy EA is an x-risk', + author: 'Obasi Shaw', + link: 'https://forum.effectivealtruism.org/posts/ByHc6jdXF9skwevYf/the-happiness-maximizer-why-ea-is-an-x-risk', + }, + { + title: 'EA is a fight against Knightian uncertainty', + author: 'Rohit (Strange Loop)', + link: 'https://forum.effectivealtruism.org/posts/vic7EdWCGKd4fYtYd/ea-is-a-fight-against-knightian-uncertainty', + }, + { + title: + 'The Malthusian Gradient: Why some third-world interventions may be doing more harm than good', + author: 'JoePater', + link: 'https://forum.effectivealtruism.org/posts/juFzy7CWhu6ApQMAA/the-malthusian-gradient-why-some-third-world-interventions', + }, + { + title: 'The Hidden Impossibilities Of Being An Effective Altruist.', + author: 'Refined Insights ', + link: 'https://forum.effectivealtruism.org/posts/fsxEDLM2oPzSREM4G/the-hidden-impossibilities-of-being-an-effective-altruist', + }, + { + title: 'A critique of strong longtermism', + author: 'Pablo Rosado', + link: 'https://forum.effectivealtruism.org/posts/ryJys2fAz7J4vAwFC/a-critique-of-strong-longtermism', + }, + { + title: 'Making EA More Effective', + author: 'Peter Kelly', + link: 'https://forum.effectivealtruism.org/posts/Ag6bsmqxwqWTSjcHX/making-ea-more-effective', + }, + { + title: "A part of the system's apology", + author: 'Niv Cohen', + link: 'https://forum.effectivealtruism.org/posts/vNTD4mBAzfyZFJkfW/a-part-of-the-system-s-apology', + }, + { + title: 'The Wages of North-Atlantic Bias', + author: 'Sach Wry', + link: 'https://forum.effectivealtruism.org/posts/FA4tC72qAB5k37uFC/the-wages-of-north-atlantic-bias', + }, + { + title: 'Keeping it Real', + author: 'calumdavey', + link: 'https://forum.effectivealtruism.org/posts/ewEiyspZeqjZC7Yh7/keeping-it-real', + }, + { + title: 'Hobbit Manifesto', + author: 'Clay Cube', + link: 'https://forum.effectivealtruism.org/posts/3caZ7LhMsvsS7kRrz/hobbit-manifesto', + }, + { + title: + "How avoiding drastic career changes could support EA's epistemic health and long-term efficacy.", + author: 'nat goldthwaite', + link: 'https://forum.effectivealtruism.org/posts/3txJdk6ZcNmcRBjWP/how-avoiding-drastic-career-changes-could-support-ea-s', + }, + { + title: + "Present-day good intentions aren't sufficient to make the longterm future good in expectation", + author: 'trurl', + link: 'https://forum.effectivealtruism.org/posts/FBNk5ibcWwYcavkh4/present-day-good-intentions-aren-t-sufficient-to-make-the', + }, + { + title: + 'A podcast episode exploring critiques of effective altruism (with Michael Nielsen and Ajeya Cotra)', + author: 'spencerg', + link: 'https://forum.effectivealtruism.org/posts/2dHk3zBmmnNTefjWB/a-podcast-episode-exploring-critiques-of-effective-altruism', + }, + { + title: 'Follow-up: Crowdsourced Criticisms', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/kXCsTDB5s7QRnWS8f/follow-up-crowdsourced-criticisms', + }, + { + title: 'Why the EA aversion to local altruistic action?', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/LnuuN7zuBSZvEo845/why-the-ea-aversion-to-local-altruistic-action', + }, + { + title: 'Effective Altruists and Religion: A Proposal for Experimentation', + author: 'Kbrown', + link: 'https://forum.effectivealtruism.org/posts/YA6fCNwB2c5cydrtG/effective-altruists-and-religion-a-proposal-for', + }, + { + title: + 'On the institutional critique of effective altruism: a response (mainly) to Brian Berkey ', + author: 'zzz1407', + link: 'https://forum.effectivealtruism.org/posts/GgNgnzjqceDghhozf/on-the-institutional-critique-of-effective-altruism-a-1', + }, + { + title: + 'We Can’t Do Long Term Utilitarian Calculations Until We Know if AIs Can Be Conscious or Not', + author: 'Mike20731', + link: 'https://forum.effectivealtruism.org/posts/Zsz3BYQTJjJdZd4DR/we-can-t-do-long-term-utilitarian-calculations-until-we-know', + }, + { + title: 'The ordinal utility argument against effective altruism ', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/rNYCcRLzkQtQEBnLa/the-ordinal-utility-argument-against-effective-altruism', + }, + { + title: + 'Reciprocity & the causes of diminishing returns: cause exploration submission', + link: 'https://forum.effectivealtruism.org/posts/x9towRLtvYidkXugk/reciprocity-and-the-causes-of-diminishing-returns-cause', + }, + { + title: + 'Altruism is systems change, so why isn’t EA? Constructive criticism.', + link: 'https://forum.effectivealtruism.org/posts/xZrvbwhSLmsGmHHSD/altruism-is-systems-change-so-why-isn-t-ea-constructive', + }, + { + title: 'The reasonableness of special concerns', + author: 'jwt', + link: 'https://forum.effectivealtruism.org/posts/CFGYLDgvsYQhsyZ42/the-reasonableness-of-special-concerns', + }, + { + title: 'The EA community should utilize the concept of beliefs more often', + author: 'Noah Scales', + link: 'https://forum.effectivealtruism.org/posts/9SKqeNSvAKozeMvGq/the-ea-community-should-utilize-the-concept-of-beliefs-more', + }, + { + title: 'Why bother doing the most good?', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/ZPcKeZbcC5SgLGLwg/why-bother-doing-the-most-good', + }, + { + title: + 'Framing EA as tending towards longtermism might be diminishing its potential impact', + author: 'Mm', + link: 'https://forum.effectivealtruism.org/posts/guGteQYvwcuDAECPA/framing-ea-as-tending-towards-longtermism-might-be', + }, + { + title: 'Bernard Williams: Ethics and the limits of impartiality', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/G6EWTrArPDf74sr3S/bernard-williams-ethics-and-the-limits-of-impartiality', + }, + { + title: 'Love and AI: Relational Brain/Mind Dynamics in AI Development', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/MdfLn33GpNWGN7CSE/love-and-ai-relational-brain-mind-dynamics-in-ai-development', + }, + { + title: 'When 2/3rds of the world goes against you', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/6va2EfHkQ3bTmdDyn/when-2-3rds-of-the-world-goes-against-you', + }, + { + title: 'My views on EA --> attempt to a constructive criticism', + author: 'Jin Jo', + link: 'https://forum.effectivealtruism.org/posts/LcDcqX6KWGHm3tSgr/my-views-on-ea-greater-than-attempt-to-a-constructive', + }, + { + title: 'Empirical critique of EA from another direction', + author: 'tonz', + link: 'https://forum.effectivealtruism.org/posts/nsqhmwmwZmWvFA2wb/empirical-critique-of-ea-from-another-direction', + }, + { + title: 'Critique: Cost-Benefit of Weirdness', + author: 'Mike Elias', + link: 'https://forum.effectivealtruism.org/posts/kw8ZmziAwcqPW2jt6/critique-cost-benefit-of-weirdness', + }, + { + title: 'Hits- or misses-based giving', + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/XzawnaT4jyqpkEihz/hits-or-misses-based-giving', + }, + { + title: 'Mind your step', + author: 'Talsome', + link: 'https://forum.effectivealtruism.org/posts/rGNaz4GtWCzPbCWCB/mind-your-step', + }, + { + title: 'Against Impartial Altruism', + author: 'Sam K', + link: 'https://forum.effectivealtruism.org/posts/f5ZxK2k9gyZthHGND/against-impartial-altruism', + }, + { + title: 'Criticism of EA and longtermism', + author: 'St. Ignorant', + link: 'https://forum.effectivealtruism.org/posts/DuG8rBSAErSmSN7uE/criticism-of-ea-and-longtermism', + }, + { + title: + 'Against Longtermism: \nI welcome our robot overlords, and you should too!', + author: 'MattBall', + link: 'https://forum.effectivealtruism.org/posts/Cuu4Jjmp7QqL4a5Ls/against-longtermism-i-welcome-our-robot-overlords-and-you', + }, + { + title: 'Effective Altruism Criticisms', + author: 'Gavin Palmer (heroLFG.com)', + link: 'https://forum.effectivealtruism.org/posts/mMaAcvNLQPC3aTqB6/effective-altruism-criticisms', + }, + { + title: '"Of Human Bondage" and Morality', + author: 'Casaubon', + link: 'https://forum.effectivealtruism.org/posts/ZJnKCToBojYqqQphb/of-human-bondage-and-morality', + }, + { + title: + 'Portfolios, Locality, and Career - Three Critiques of Effective Altruism', + author: 'Philip Apps', + link: 'https://forum.effectivealtruism.org/posts/AoL2h2ZqTSNevdtRM/portfolios-locality-and-career-three-critiques-of-effective', + }, + { + title: 'Effective altruism in a non-ideal world', + author: 'Eric Kramer', + link: 'https://forum.effectivealtruism.org/posts/2nApcLJsZeABu38uW/effective-altruism-in-a-non-ideal-world', + }, + { + title: 'The future of humanity', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/nLyG65eQepKKeGbrg/the-future-of-humanity', + }, + { + title: + 'Investigating Ideology: want to earn money, help EA and/or me? Then check this out; it may be a mighty neglected cause ', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/twaKWNjAc4KEz3kMq/investigating-ideology-want-to-earn-money-help-ea-and-or-me', + }, + { + title: 'Accepting the Inevitability of Ambitious Egoism (”AE”)', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/bQsxsaEcvxzEML9ZW/accepting-the-inevitability-of-ambitious-egoism-ae', + }, + { + title: 'Book Review: What We Owe The Future (Erik Hoel)', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/AyPTZLTwm5hN2Kfcb/book-review-what-we-owe-the-future-erik-hoel', + }, + { + title: + 'Effective Altruism Goes Political: Normative Conflicts and Practical Judgment', + author: 'Michael Haiden', + link: 'https://forum.effectivealtruism.org/posts/aisE9yhZHuiM9Cdn7/effective-altruism-goes-political-normative-conflicts-and-1', + }, + { + title: 'Values lock-in is already happening (without AGI)', + link: 'https://forum.effectivealtruism.org/posts/ogwD28mzJy8dkwtmc/values-lock-in-is-already-happening-without-agi', + }, + { + title: 'EA Should Rename Itself', + author: 'Name Rectifier', + link: 'https://forum.effectivealtruism.org/posts/swkxLtjG9z7RY7i9x/ea-should-rename-itself', + }, + { + title: 'What we are for? Community, Correction and Scale [wip]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/QCv5GNcQFeH34iN2w/what-we-are-for-community-correction-and-scale-wip', + }, + { + title: '“One should love one’s neighbor more than oneself.”', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/bxbu8v83gw3MDzCBX/one-should-love-one-s-neighbor-more-than-oneself', + }, + { + title: 'Run For President', + author: 'Brian Moore', + link: 'https://forum.effectivealtruism.org/posts/ZniCnE8XhCMLeGHj8/run-for-president', + }, + { + title: 'Effective Altruism Risks Perpetuating a Harmful Worldview', + author: 'Theo Cox', + link: 'https://forum.effectivealtruism.org/posts/QRaf9iWvGbfKgWBvY/effective-altruism-risks-perpetuating-a-harmful-worldview', + }, +] diff --git a/functions/src/scripts/contest/scrape-ea.ts b/functions/src/scripts/contest/scrape-ea.ts new file mode 100644 index 00000000..c22f4ac7 --- /dev/null +++ b/functions/src/scripts/contest/scrape-ea.ts @@ -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' +) diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 3240357e..b2e4c4d8 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -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 diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 6d062d40..99ac6281 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -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}.`) diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts new file mode 100644 index 00000000..41aa9fe9 --- /dev/null +++ b/functions/src/test-scheduled-function.ts @@ -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 } + } +) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 6bb8349a..efc22e53 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -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}` +} diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index bec5949c..7c6f21a4 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -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 diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts new file mode 100644 index 00000000..dcbb68dd --- /dev/null +++ b/functions/src/weekly-portfolio-emails.ts @@ -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( + 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( + 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( + 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(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 +} diff --git a/web/components/add-funds-button.tsx b/web/components/add-funds-button.tsx index 90b24b2c..b610bfee 100644 --- a/web/components/add-funds-button.tsx +++ b/web/components/add-funds-button.tsx @@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
-
Get Manifold Dollars
+
Get Mana
- Use Manifold Dollars to trade in your favorite markets.
(Not + Buy mana (M$) to trade in your favorite markets.
(Not redeemable for cash.)
diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 01eb1efb..cded285a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -38,6 +38,8 @@ export function AmountInput(props: { const { width } = useWindowSize() const isMobile = (width ?? 0) < 768 + const isMobile = useIsMobile(768) + return ( <> diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index d7c7b717..19ced0b2 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -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' diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 44c37128..abb67d46 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -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 diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 97d11758..dbb2db56 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -77,7 +77,7 @@ export function BetsList(props: { user: User }) { }, [contractList]) const [sort, setSort] = useState('newest') - const [filter, setFilter] = useState('open') + const [filter, setFilter] = useState('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 ( - - - -
Investment value
-
- {formatMoney(currentNetInvestment)}{' '} - -
- - -
Total profit
-
- {formatMoney(totalPnl)}{' '} - -
- -
+ + +
+ Investment value +
+
+ {formatMoney(currentNetInvestment)}{' '} + +
+ - + - + {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 = diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx index bf3730f3..3ba6f2ce 100644 --- a/web/components/comment-input.tsx +++ b/web/components/comment-input.tsx @@ -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: { [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: { {user && !isSubmitting && ( + + + + {children} + </Col> + </Modal> + </> + ) +} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 367a5401..aa130321 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -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> + ) +} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 4d25ffa4..f984e3b6 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -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"> diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index aad44b82..60ef85b5 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -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 }} diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 245a8d7d..17471796 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -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> - ))} - </> - ) -} +}) diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 3da9a5d5..d6c9c5fa 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -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} /> ) } diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index af5db9c3..8f4b5579 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -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> ) } diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 01dce32f..7e0c765a 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -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> ) } diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 0c7fdfbc..07b7c659 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -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)} diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index e1eb26eb..72c7aba3 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -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" diff --git a/web/components/create-post.tsx b/web/components/create-post.tsx new file mode 100644 index 00000000..c176e61d --- /dev/null +++ b/web/components/create-post.tsx @@ -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> + ) +} diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 27f0f731..e17ea578 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -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> )} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index acb48ec1..1b62690b 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -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} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 0e65165b..319d4af6 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -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> ) } diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index fdf739a1..c897c89b 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -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', diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-about-post.tsx index b76d8037..4d3046e9 100644 --- a/web/components/groups/group-about-post.tsx +++ b/web/components/groups/group-about-post.tsx @@ -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. diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index e6271466..5c9d2edd 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -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> diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index f0dae17d..54e501b2 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -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"> diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 3d72b13c..b82131ec 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -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> diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index a0670795..1035e9d1 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -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> ))} diff --git a/web/components/nav/group-nav-bar.tsx b/web/components/nav/group-nav-bar.tsx deleted file mode 100644 index 9ea3f5a4..00000000 --- a/web/components/nav/group-nav-bar.tsx +++ /dev/null @@ -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> - ) -} diff --git a/web/components/nav/group-sidebar.tsx b/web/components/nav/group-sidebar.tsx deleted file mode 100644 index a68064e0..00000000 --- a/web/components/nav/group-sidebar.tsx +++ /dev/null @@ -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> - ) -} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 45347774..b0a9862b 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -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" />} diff --git a/web/components/onboarding/welcome.tsx b/web/components/onboarding/welcome.tsx index b18ef83f..eb51f2de 100644 --- a/web/components/onboarding/welcome.tsx +++ b/web/components/onboarding/welcome.tsx @@ -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 }) } diff --git a/web/components/page.tsx b/web/components/page.tsx index 9b26e9f8..f72db80e 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -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', diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index d8489b47..6ed5d195 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -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 +} diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a0006c60..ec364c8d 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -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> + ) +} diff --git a/web/components/profile/user-likes-button.tsx b/web/components/profile/user-likes-button.tsx index 3d4fa9ac..666036a8 100644 --- a/web/components/profile/user-likes-button.tsx +++ b/web/components/profile/user-likes-button.tsx @@ -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> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index 4b4f7095..9a548031 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -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', diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 7aef6189..46a988f6 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -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 diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2b24fa60..bcbb395e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -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> + ) +} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index f2403a15..11aae65c 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -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 } ) } diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 9bcb59cd..e918aa8c 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -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 diff --git a/web/hooks/use-is-mobile.ts b/web/hooks/use-is-mobile.ts index 9ce0133c..7e99a97d 100644 --- a/web/hooks/use-is-mobile.ts +++ b/web/hooks/use-is-mobile.ts @@ -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 } diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index 6cc1a84e..1ae5e7ee 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -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 } ) } diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts index 9daf2d22..ff7bf6b9 100644 --- a/web/hooks/use-post.ts +++ b/web/hooks/use-post.ts @@ -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) +} diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts index 46d78b3c..0e83613b 100644 --- a/web/hooks/use-prefetch.ts +++ b/web/hooks/use-prefetch.ts @@ -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'), ]) } diff --git a/web/hooks/use-prob-changes.tsx b/web/hooks/use-prob-changes.tsx index f3a6eee9..132cfd64 100644 --- a/web/hooks/use-prob-changes.tsx +++ b/web/hooks/use-prob-changes.tsx @@ -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() } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index 3731fb07..c28b453d 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -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 } ) } diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 6b1b43d8..8aa7a067 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -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) } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 33f6533b..d7f6cd88 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -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> diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 61424b8f..17e41c53 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -43,6 +43,7 @@ export function groupPath( | 'about' | typeof GROUP_CHAT_SLUG | 'leaderboards' + | 'posts' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 162933af..36007048 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -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)) +} diff --git a/web/lib/service/algolia.ts b/web/lib/service/algolia.ts index 3b6648a1..29cbd6bf 100644 --- a/web/lib/service/algolia.ts +++ b/web/lib/service/algolia.ts @@ -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') +) diff --git a/web/next.config.js b/web/next.config.js index 21b375ba..cf727fd4 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -9,9 +9,6 @@ module.exports = { reactStrictMode: true, optimizeFonts: false, experimental: { - images: { - allowFutureImage: true, - }, scrollRestoration: true, externalDir: true, modularizeImports: { diff --git a/web/package.json b/web/package.json index ba25a6e1..3ccbc96c 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 3682e700..38df2fbf 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -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> - ) -} +) diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index d5a38272..3e82d029 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -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> diff --git a/web/pages/add-funds.tsx b/web/pages/add-funds.tsx index ed25a21a..602de276 100644 --- a/web/pages/add-funds.tsx +++ b/web/pages/add-funds.tsx @@ -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> diff --git a/web/pages/api/v0/market/[id]/close.ts b/web/pages/api/v0/market/[id]/close.ts new file mode 100644 index 00000000..d1c9ac5e --- /dev/null +++ b/web/pages/api/v0/market/[id]/close.ts @@ -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.' }) + } +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index 11d0f9ab..16999aaa 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -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> ) diff --git a/web/pages/create-post.tsx b/web/pages/create-post.tsx deleted file mode 100644 index 01147cc0..00000000 --- a/web/pages/create-post.tsx +++ /dev/null @@ -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> - ) -} diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx index 3b709d89..0a17e9e2 100644 --- a/web/pages/daily-movers.tsx +++ b/web/pages/daily-movers.tsx @@ -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') diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 62dd1ae1..75a9ad05 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -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 diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3adb01c1..a23ce602 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -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) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 1854da34..d5c73913 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -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> diff --git a/web/pages/home/edit.tsx b/web/pages/home/edit.tsx index 48e10c6c..8c5f8ab5 100644 --- a/web/pages/home/edit.tsx +++ b/web/pages/home/edit.tsx @@ -28,7 +28,7 @@ export default function Home() { } const groups = useMemberGroupsSubscription(user) - const { sections } = getHomeItems(groups, homeSections) + const { sections } = getHomeItems(groups ?? [], homeSections) return ( <Page> diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 83bcb15b..b42b37bb 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -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"> diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 4f1e9437..e663d81c 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -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={[ diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 907157f9..4f9700dd 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -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> ) } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 2c095db6..caa9f47a 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -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'], diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 40847470..19fab509 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -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={[ { diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index b56e55e6..0b9dbc80 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -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?', diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 22d3152d..6508a69e 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -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> </> ) diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index d129f807..b98887bb 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -92,7 +92,7 @@ export function PostCommentInput(props: { return ( <CommentInput - replyToUser={replyToUser} + replyTo={replyToUser} parentCommentId={parentCommentId} onSubmitComment={onSubmitComment} /> diff --git a/web/public/praying-mantis-light.svg b/web/public/praying-mantis-light.svg deleted file mode 100644 index cc82cd53..00000000 --- a/web/public/praying-mantis-light.svg +++ /dev/null @@ -1,67 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - id="svg2" - xml:space="preserve" - width="2829.3333" - height="2829.3333" - viewBox="0 0 2829.3333 2829.3333" - sodipodi:docname="shutterstock_2073140717.eps"><metadata - id="metadata8"><rdf:RDF><cc:Work - rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs - id="defs6" /><sodipodi:namedview - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1" - objecttolerance="10" - gridtolerance="10" - guidetolerance="10" - inkscape:pageopacity="0" - inkscape:pageshadow="2" - inkscape:window-width="640" - inkscape:window-height="480" - id="namedview4" /><g - id="g10" - inkscape:groupmode="layer" - inkscape:label="ink_ext_XXXXXX" - transform="matrix(1.3333333,0,0,-1.3333333,0,2829.3333)"><g - id="g12" - transform="scale(0.1)"><path - d="m 10622.5,11248.2 c -81.6,-54.3 -178.4,-122.5 -292.3,-197.1 -114.1,-74.3 -241.9,-159.2 -384.07,-247.8 -140.7,-90.6 -296.56,-183.4 -460.04,-283.5 -81.17,-50.9 -167.57,-98.4 -254.29,-149.2 -87.66,-49.5 -174.65,-104.3 -266.68,-153.9 -91.29,-50.7 -184.14,-102.4 -278.09,-154.6 -93.94,-52.3 -191.33,-100.9 -287.65,-153.3 -192.27,-105.6 -393.64,-201 -592.62,-301 -99.96,-49 -201.33,-94.9 -301.6,-142.3 -100.32,-47.3 -200.08,-94.8 -301.41,-136.4 -100.82,-42.9 -200.55,-86.1 -298.87,-129.4 -98.47,-42.8 -198.16,-79 -294.96,-118 -97.38,-37.3 -191.91,-77.8 -286.76,-110.5 -94.68,-33.6 -187.3,-66.4 -277.42,-98.3 -45.11,-15.9 -89.61,-31.7 -133.44,-47.3 -44.37,-13.7 -88.12,-27.1 -131.09,-40.4 -86.05,-26.7 -169.14,-52.4 -248.91,-77.2 -39.96,-12.3 -79.06,-24.4 -117.3,-36.2 -38.71,-9.8 -76.56,-19.5 -113.44,-28.9 -73.75,-19.2 -143.71,-37.5 -209.45,-54.6 -65.9,-16.6 -127.23,-34.6 -184.73,-46.9 -57.61,-11.8 -110.58,-22.6 -158.43,-32.4 -191.41,-40.4 -300.86,-63.6 -301.14,-63.6 0.2,0 111.49,12.4 306.1,34.2 48.79,5.8 102.77,12.2 161.6,19.2 58.83,7.2 121.13,21.7 188.51,33.5 67.27,12.6 138.87,26.1 214.34,40.3 37.73,7.3 76.44,14.7 116.05,22.4 39.11,9.8 79.11,19.8 119.97,29.9 81.75,20.6 166.87,42 255.03,64.2 44.07,11.2 88.95,22.5 134.18,35 44.77,13.9 90.28,28.1 136.37,42.4 92.31,28.7 187.07,58.2 283.99,88.3 48.78,14.2 96.99,31.6 145.43,49.7 48.63,17.8 97.69,35.8 147.18,53.9 98.64,37.1 200.35,71.4 300.78,112.2 100.47,40.8 201.92,82.9 303.87,125.8 102.74,41.4 203.75,88.5 305.43,135.4 101.49,47.1 204.38,92.5 305.51,141.4 200,102.5 402.34,200.4 595.35,308.4 195.7,103 382.34,214.9 563.79,321.6 91.25,52.3 178.28,108.5 264.06,162.3 85.31,54.6 170.2,105.7 249.92,160 80.24,53.6 158.25,105.6 233.75,155.9 75.39,50.4 145.9,102.7 214.81,150.9 138.27,95.7 260.57,190 369.47,272.5 109.4,81.8 200.6,159.2 277.3,220.1 76,61.8 132.8,113.9 172.9,147.7 39.7,34.4 60.8,52.8 60.8,52.8 0,0 -91.6,-64.5 -251.8,-177.2" - style="fill:#668a29;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path14" /><path - d="m 15228.8,15825 c -102.2,-173.3 -176.3,-358 -218.3,-550.5 -19.6,-17.6 -37.4,-37 -53.4,-58 -163.6,59.3 -342.5,63.1 -484.2,31 54.1,715.2 441.9,1360.2 1047.5,1839.3 653.8,517 1560.1,838.1 2563.2,838 v 99.8 c -1024.4,-0.1 -1951.8,-327.4 -2625.1,-859.5 -610.2,-482.2 -1010.9,-1135.1 -1080.6,-1863.1 24.7,-17.7 47.3,-38.1 67.4,-60.7 64.3,-72.4 103.6,-167.4 103.6,-271.7 0,-169.6 -103,-315.2 -250,-377.3 -49.1,-20.7 -102.9,-32.2 -159.4,-32.2 -188.5,0 -346.9,127.6 -394.5,301 -361.9,-310.9 -784.7,-754 -1230.9,-1104.8 -123.3,-97 -259.3,-238.4 -399.4,-403.4 -69.3,-51.5 -131.7,-122.5 -178.1,-219.4 -456.2,-583.6 -916.7,-1325.1 -1094.3,-1547.3 -457.6,-216.4 -911.4,-311.1 -1384.14,-404.1 -205.43,-40.3 -414.41,-80.5 -628.86,-130 -130.39,-30.2 -262.89,-64.1 -397.74,-103.4 -167.58,-48.9 -338.83,-106.5 -514.76,-177.3 -199.85,-80.5 -405.55,-177.8 -618.36,-298.2 -39.92,-22.7 -78.24,-45.2 -117.42,-67.8 -174.61,-100.7 -341.76,-201.1 -501.61,-300.9 C 6201.64,9806 5787.19,9513.4 5425,9237.8 c -215.78,-164.2 -413.55,-322.4 -595.2,-472.1 -135.85,-111.9 -262.73,-218.9 -381.99,-320.3 0.28,0 109.73,23.2 301.14,63.6 47.85,9.8 100.82,20.6 158.43,32.4 57.5,12.3 118.83,30.3 184.73,46.9 65.74,17.1 135.7,35.4 209.45,54.6 36.88,9.4 74.73,19.1 113.44,28.9 38.24,11.8 77.34,23.9 117.3,36.2 79.77,24.8 162.86,50.5 248.91,77.2 42.97,13.3 86.72,26.7 131.09,40.4 43.83,15.6 88.33,31.4 133.44,47.3 90.12,31.9 182.74,64.7 277.42,98.3 94.85,32.7 189.38,73.2 286.76,110.5 96.8,39 196.49,75.2 294.96,118 98.32,43.3 198.05,86.5 298.87,129.4 101.33,41.6 201.09,89.1 301.41,136.4 100.27,47.4 201.64,93.3 301.6,142.3 198.98,100 400.35,195.4 592.62,301 96.32,52.4 193.71,101 287.65,153.3 93.95,52.2 186.8,103.9 278.09,154.6 92.03,49.6 179.02,104.4 266.68,153.9 86.72,50.8 173.12,98.3 254.29,149.2 163.48,100.1 319.34,192.9 460.04,283.5 142.17,88.6 269.97,173.5 384.07,247.8 113.9,74.6 210.7,142.8 292.3,197.1 160.2,112.7 251.8,177.2 251.8,177.2 0,0 -21.1,-18.4 -60.8,-52.8 -40.1,-33.8 -96.9,-85.9 -172.9,-147.7 -76.7,-60.9 -167.9,-138.3 -277.3,-220.1 -108.9,-82.5 -231.2,-176.8 -369.47,-272.5 -68.91,-48.2 -139.42,-100.5 -214.81,-150.9 -75.5,-50.3 -153.51,-102.3 -233.75,-155.9 -79.72,-54.3 -164.61,-105.4 -249.92,-160 -85.78,-53.8 -172.81,-110 -264.06,-162.3 -181.45,-106.7 -368.09,-218.6 -563.79,-321.6 -193.01,-108 -395.35,-205.9 -595.35,-308.4 -101.13,-48.9 -204.02,-94.3 -305.51,-141.4 -101.68,-46.9 -202.69,-94 -305.43,-135.4 -101.95,-42.9 -203.4,-85 -303.87,-125.8 -100.43,-40.8 -202.14,-75.1 -300.78,-112.2 -49.49,-18.1 -98.55,-36.1 -147.18,-53.9 -48.44,-18.1 -96.65,-35.5 -145.43,-49.7 -96.92,-30.1 -191.68,-59.6 -283.99,-88.3 -46.09,-14.3 -91.6,-28.5 -136.37,-42.4 -45.23,-12.5 -90.11,-23.8 -134.18,-35 -88.16,-22.2 -173.28,-43.6 -255.03,-64.2 -40.86,-10.1 -80.86,-20.1 -119.97,-29.9 -39.61,-7.7 -78.32,-15.1 -116.05,-22.4 -75.47,-14.2 -147.07,-27.7 -214.34,-40.3 -67.38,-11.8 -129.68,-26.3 -188.51,-33.5 -58.83,-7 -112.81,-13.4 -161.6,-19.2 -194.61,-21.8 -305.9,-34.2 -306.1,-34.2 -663.4,-564.2 -1086.87,-950 -1454.8,-983.5 72.89,-128.8 730.04,15.1 1664.29,339.7 141.8,49.3 289.85,102.7 443.36,160 51.41,19.1 103.44,38.8 156.02,58.8 52.23,-252.7 180.66,-592.2 394.45,-911.7 142.27,-212.7 291.06,-397.8 427.27,-538.4 l -3.79,-2.3 C 5872.3,6432.5 3914.65,3342.3 4005.23,3206.8 c 90.63,-135.4 2195.32,2735.1 2397.7,2870.4 202.34,135.4 292.97,354.9 202.3,490.5 -12.93,19.3 -29.18,35.6 -47.61,49.4 -63.56,215.9 -210.74,513.2 -415.86,819.8 -123.63,184.9 -164.26,536.4 -199.73,855.9 569.34,235 1177.93,507.5 1784.73,805.2 87.85,-408.4 251.83,-803.5 371.33,-959.7 97.34,-159.8 600.11,-435.7 1207.54,-515.4 253.75,-33.3 490.97,-43.5 686.6,-33.8 l -0.75,-4.3 c -31.64,-241.4 1000.52,-3751 1162.12,-3772.2 161.7,-21.2 -608.5,3453.9 -576.8,3695.4 31.7,241.3 -73.7,454.2 -235.3,475.4 -23.1,3.1 -46,1.6 -68.6,-3.1 -204.1,94.5 -524.66,179.9 -890.44,227.9 -242.62,31.8 -570.78,272.4 -853.59,468.9 40.08,153.4 74.26,327.7 98.67,514 17.73,135 28.87,265.3 33.98,387.3 141.41,76.3 281.53,153.6 419.73,231.8 190.35,107.7 373.91,215.7 551.95,323.5 148.87,90.2 293.4,180.2 433.4,269.7 798.4,510.8 1451.5,1007.5 1939,1436.7 86.8,-272.8 194.7,-506.2 280,-617.7 97.3,-159.7 600.1,-435.6 1207.6,-515.3 253.7,-33.4 490.9,-43.5 686.6,-33.8 l -0.8,-4.4 c -31.7,-241.4 1000.5,-3750.9 1162.2,-3772.1 161.6,-21.2 -608.6,3453.9 -576.9,3695.3 31.7,241.4 -73.7,454.3 -235.3,475.5 -23.1,3 -46,1.5 -68.5,-3.2 -204.2,94.5 -524.8,179.9 -890.5,227.9 -242.6,31.9 -570.8,272.5 -853.6,469 40.1,153.4 74.2,327.7 98.7,513.9 17.4,132.5 28.4,260.4 33.7,380.4 0.9,20.9 1.6,41.5 2.2,61.9 102.2,140.7 160.6,254.7 171.7,334.9 146.6,101.3 290.1,206.2 425.5,312.6 94.5,74.3 186.3,144.3 274.9,210.9 323.5,243.3 601.7,440.3 795.1,626.5 239.4,-336.9 587.3,-500.1 789.4,-364.5 144.8,97.1 192.2,434.7 126.7,751.4 151.3,60.1 258.4,207.6 258.4,380.3 0,226.1 -183.3,409.5 -409.5,409.5 -53.4,0 -104.3,-10.6 -151.1,-29.2 41.3,147 102.8,289 182.7,424.4 236.5,401.5 634.1,745.9 1137.5,989.6 503.4,243.7 1111.9,386.5 1767.9,386.5 v 99.8 c -893.9,-0.1 -1703.5,-259 -2292.2,-680.9 -294.2,-210.9 -533.4,-462.9 -699.2,-744.3" - style="fill:#bfe142;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path16" /><path - d="m 14096.4,15241.1 c -14.7,4.9 -30.1,8.2 -46.6,8.2 -81.6,0 -147.8,-66.2 -147.8,-148 0,-4.7 0.9,-9.3 1.4,-14 7.1,-75 69.6,-133.8 146.4,-133.8 81.7,0 148,66.2 148,147.8 0,65.3 -42.6,120.1 -101.4,139.8" - style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path18" /><path - d="m 14139.5,14802.3 c -153.4,0 -277.7,222.5 -277.7,222.5 0,0 15.3,27.3 41.6,62.5 -0.5,4.7 -1.4,9.3 -1.4,14 0,81.8 66.2,148 147.8,148 16.5,0 31.9,-3.3 46.6,-8.2 14.1,3.6 28.4,6.2 43.1,6.2 153.3,0 277.6,-222.5 277.6,-222.5 0,0 -124.3,-222.5 -277.6,-222.5 z m 238.4,499.7 c -67.2,48.3 -149.3,77.1 -238.4,77.1 -226.1,0 -409.5,-183.4 -409.5,-409.5 0,-37.7 5.5,-73.9 15,-108.5 47.6,-173.4 206,-301 394.5,-301 56.5,0 110.3,11.5 159.4,32.2 147,62.1 250,207.7 250,377.3 0,104.3 -39.3,199.3 -103.6,271.7 -20.1,22.6 -42.7,43 -67.4,60.7" - style="fill:#d4e61d;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path20" /><path - d="m 14139.5,15247.3 c -14.7,0 -29,-2.6 -43.1,-6.2 58.8,-19.7 101.4,-74.5 101.4,-139.8 0,-81.6 -66.3,-147.8 -148,-147.8 -76.8,0 -139.3,58.8 -146.4,133.8 -26.3,-35.2 -41.6,-62.5 -41.6,-62.5 0,0 124.3,-222.5 277.7,-222.5 153.3,0 277.6,222.5 277.6,222.5 0,0 -124.3,222.5 -277.6,222.5" - style="fill:#293519;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path22" /><path - d="m 5843.09,10913.9 1.01,4.3 c 120.16,-58.3 516.88,-510.3 835.31,-813.7 159.85,99.8 327,200.2 501.61,300.9 -364.5,119.2 -1220.16,989.6 -1442,982.7 -18.82,13.2 -39.37,23.6 -61.83,29.8 -157.03,43.9 -337.35,-110.8 -402.78,-345.3 -27.89,-100 -231.91,-1141.6 -444.61,-2306.9 181.65,149.7 379.42,307.9 595.2,472.1 221.99,884.7 395.16,1594 418.09,1676.1" - style="fill:#96d42f;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path24" /><path - d="m 4332.03,5382.5 c 76.92,-21.4 425.39,1249.4 768.63,2579.1 -153.51,-57.3 -301.56,-110.7 -443.36,-160 C 4437.54,6545 4260.27,5402.5 4332.03,5382.5" - style="fill:#96d42f;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path26" /><path - d="m 8577.89,11337.9 2.89,-3.4 c 23.01,-24.8 112.07,-126.4 248.52,-282.4 214.45,49.5 423.43,89.7 628.86,130 -243.75,309.5 -410.74,517.5 -444.37,553.8 -165.47,178.5 -396.56,233.5 -516.09,122.7 -17.15,-15.9 -30.67,-34.5 -41.37,-54.8 -133.2,-63.4 -338.01,-599.7 -539.53,-1032.4 175.93,70.8 347.18,128.4 514.76,177.3 58.36,189 109.38,339.9 146.33,389.2" - style="fill:#96d42f;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path28" /><path - d="m 12455.6,7107.7 c 78.1,72.5 -1347,1953 -2389,3295.7 -140,-89.5 -284.53,-179.5 -433.4,-269.7 1121.5,-1276.9 2742.7,-3099.8 2822.4,-3026" - style="fill:#96d42f;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path30" /><path - d="m 14364.1,11431.3 c 253.8,-33.3 491,-43.5 686.6,-33.8 l -0.7,-4.4 c -31.7,-241.4 1000.4,-3750.9 1162.1,-3772.1 161.7,-21.2 -608.5,3453.9 -576.8,3695.3 31.7,241.4 -73.7,454.3 -235.3,475.5 -23.1,3.1 -46.1,1.6 -68.6,-3.1 -204.2,94.5 -524.7,179.9 -890.5,227.9 -242.6,31.9 -570.8,272.5 -853.6,469 40.1,153.3 74.3,327.7 98.7,513.9 27.3,207.8 39,404.5 37.1,576.4 -88.6,-66.6 -180.4,-136.6 -274.9,-210.9 -135.4,-106.4 -278.9,-211.3 -425.5,-312.6 -11.1,-80.2 -69.5,-194.2 -171.7,-334.9 -0.6,-20.4 -1.3,-41 -2.2,-61.9 90.5,-311.4 213.1,-585.2 307.7,-709 97.4,-159.7 600.2,-435.6 1207.6,-515.3" - style="fill:#96d42f;fill-opacity:1;fill-rule:nonzero;stroke:none" - id="path32" /></g></g></svg> diff --git a/web/public/world-trading-background.webp b/web/public/world-trading-background.webp deleted file mode 100644 index 502beb29..00000000 Binary files a/web/public/world-trading-background.webp and /dev/null differ diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 9de3f954..ef7220ec 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -15,9 +15,6 @@ module.exports = { } ), extend: { - backgroundImage: { - 'world-trading': "url('/world-trading-background.webp')", - }, colors: { 'red-25': '#FDF7F6', 'greyscale-1': '#FBFBFF', diff --git a/yarn.lock b/yarn.lock index 89d43cba..9829f0b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2476,10 +2476,10 @@ resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== -"@next/env@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.5.tgz#d908c57b35262b94db3e431e869b72ac3e1ad3e3" - integrity sha512-vLPLV3cpPGjUPT3PjgRj7e3nio9t6USkuew3JE/jMeon/9Mvp1WyR18v3iwnCuX7eUAm1HmAbJHHLAbcu/EJcw== +"@next/env@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/env/-/env-12.3.1.tgz#18266bd92de3b4aa4037b1927aa59e6f11879260" + integrity sha512-9P9THmRFVKGKt9DYqeC2aKIxm8rlvkK38V1P1sRE7qyoPBIs8l9oo79QoSdPtOWfzkbDAVUqvbQGgTMsb8BtJg== "@next/eslint-plugin-next@12.1.6": version "12.1.6" @@ -2488,167 +2488,164 @@ dependencies: glob "7.1.7" -"@next/swc-android-arm-eabi@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.5.tgz#903a5479ab4c2705d9c08d080907475f7bacf94d" - integrity sha512-cPWClKxGhgn2dLWnspW+7psl3MoLQUcNqJqOHk2BhNcou9ARDtC0IjQkKe5qcn9qg7I7U83Gp1yh2aesZfZJMA== +"@next/swc-android-arm-eabi@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.1.tgz#b15ce8ad376102a3b8c0f3c017dde050a22bb1a3" + integrity sha512-i+BvKA8tB//srVPPQxIQN5lvfROcfv4OB23/L1nXznP+N/TyKL8lql3l7oo2LNhnH66zWhfoemg3Q4VJZSruzQ== -"@next/swc-android-arm64@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.5.tgz#2f9a98ec4166c7860510963b31bda1f57a77c792" - integrity sha512-vMj0efliXmC5b7p+wfcQCX0AfU8IypjkzT64GiKJD9PgiA3IILNiGJr1fw2lyUDHkjeWx/5HMlMEpLnTsQslwg== +"@next/swc-android-arm64@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.3.1.tgz#85d205f568a790a137cb3c3f720d961a2436ac9c" + integrity sha512-CmgU2ZNyBP0rkugOOqLnjl3+eRpXBzB/I2sjwcGZ7/Z6RcUJXK5Evz+N0ucOxqE4cZ3gkTeXtSzRrMK2mGYV8Q== -"@next/swc-darwin-arm64@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.5.tgz#31b1c3c659d54be546120c488a1e1bad21c24a1d" - integrity sha512-VOPWbO5EFr6snla/WcxUKtvzGVShfs302TEMOtzYyWni6f9zuOetijJvVh9CCTzInnXAZMtHyNhefijA4HMYLg== +"@next/swc-darwin-arm64@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.1.tgz#b105457d6760a7916b27e46c97cb1a40547114ae" + integrity sha512-hT/EBGNcu0ITiuWDYU9ur57Oa4LybD5DOQp4f22T6zLfpoBMfBibPtR8XktXmOyFHrL/6FC2p9ojdLZhWhvBHg== -"@next/swc-darwin-x64@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.5.tgz#2e44dd82b2b7fef88238d1bc4d3bead5884cedfd" - integrity sha512-5o8bTCgAmtYOgauO/Xd27vW52G2/m3i5PX7MUYePquxXAnX73AAtqA3WgPXBRitEB60plSKZgOTkcpqrsh546A== +"@next/swc-darwin-x64@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.1.tgz#6947b39082271378896b095b6696a7791c6e32b1" + integrity sha512-9S6EVueCVCyGf2vuiLiGEHZCJcPAxglyckTZcEwLdJwozLqN0gtS0Eq0bQlGS3dH49Py/rQYpZ3KVWZ9BUf/WA== -"@next/swc-freebsd-x64@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.5.tgz#e24e75d8c2581bfebc75e4f08f6ddbd116ce9dbd" - integrity sha512-yYUbyup1JnznMtEBRkK4LT56N0lfK5qNTzr6/DEyDw5TbFVwnuy2hhLBzwCBkScFVjpFdfiC6SQAX3FrAZzuuw== +"@next/swc-freebsd-x64@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.1.tgz#2b6c36a4d84aae8b0ea0e0da9bafc696ae27085a" + integrity sha512-qcuUQkaBZWqzM0F1N4AkAh88lLzzpfE6ImOcI1P6YeyJSsBmpBIV8o70zV+Wxpc26yV9vpzb+e5gCyxNjKJg5Q== -"@next/swc-linux-arm-gnueabihf@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.5.tgz#46d8c514d834d2b5f67086013f0bd5e3081e10b9" - integrity sha512-2ZE2/G921Acks7UopJZVMgKLdm4vN4U0yuzvAMJ6KBavPzqESA2yHJlm85TV/K9gIjKhSk5BVtauIUntFRP8cg== +"@next/swc-linux-arm-gnueabihf@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.1.tgz#6e421c44285cfedac1f4631d5de330dd60b86298" + integrity sha512-diL9MSYrEI5nY2wc/h/DBewEDUzr/DqBjIgHJ3RUNtETAOB3spMNHvJk2XKUDjnQuluLmFMloet9tpEqU2TT9w== -"@next/swc-linux-arm64-gnu@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.5.tgz#91f725ac217d3a1f4f9f53b553615ba582fd3d9f" - integrity sha512-/I6+PWVlz2wkTdWqhlSYYJ1pWWgUVva6SgX353oqTh8njNQp1SdFQuWDqk8LnM6ulheVfSsgkDzxrDaAQZnzjQ== +"@next/swc-linux-arm64-gnu@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.1.tgz#8863f08a81f422f910af126159d2cbb9552ef717" + integrity sha512-o/xB2nztoaC7jnXU3Q36vGgOolJpsGG8ETNjxM1VAPxRwM7FyGCPHOMk1XavG88QZSQf+1r+POBW0tLxQOJ9DQ== -"@next/swc-linux-arm64-musl@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.5.tgz#e627e8c867920995810250303cd9b8e963598383" - integrity sha512-LPQRelfX6asXyVr59p5sTpx5l+0yh2Vjp/R8Wi4X9pnqcayqT4CUJLiHqCvZuLin3IsFdisJL0rKHMoaZLRfmg== +"@next/swc-linux-arm64-musl@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.1.tgz#0038f07cf0b259d70ae0c80890d826dfc775d9f3" + integrity sha512-2WEasRxJzgAmP43glFNhADpe8zB7kJofhEAVNbDJZANp+H4+wq+/cW1CdDi8DqjkShPEA6/ejJw+xnEyDID2jg== -"@next/swc-linux-x64-gnu@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.5.tgz#83a5e224fbc4d119ef2e0f29d0d79c40cc43887e" - integrity sha512-0szyAo8jMCClkjNK0hknjhmAngUppoRekW6OAezbEYwHXN/VNtsXbfzgYOqjKWxEx3OoAzrT3jLwAF0HdX2MEw== +"@next/swc-linux-x64-gnu@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.1.tgz#c66468f5e8181ffb096c537f0dbfb589baa6a9c1" + integrity sha512-JWEaMyvNrXuM3dyy9Pp5cFPuSSvG82+yABqsWugjWlvfmnlnx9HOQZY23bFq3cNghy5V/t0iPb6cffzRWylgsA== -"@next/swc-linux-x64-musl@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.5.tgz#be700d48471baac1ec2e9539396625584a317e95" - integrity sha512-zg/Y6oBar1yVnW6Il1I/08/2ukWtOG6s3acdJdEyIdsCzyQi4RLxbbhkD/EGQyhqBvd3QrC6ZXQEXighQUAZ0g== +"@next/swc-linux-x64-musl@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.1.tgz#c6269f3e96ac0395bc722ad97ce410ea5101d305" + integrity sha512-xoEWQQ71waWc4BZcOjmatuvPUXKTv6MbIFzpm4LFeCHsg2iwai0ILmNXf81rJR+L1Wb9ifEke2sQpZSPNz1Iyg== -"@next/swc-win32-arm64-msvc@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.5.tgz#a93e958133ad3310373fda33a79aa10af2a0aa97" - integrity sha512-3/90DRNSqeeSRMMEhj4gHHQlLhhKg5SCCoYfE3kBjGpE63EfnblYUqsszGGZ9ekpKL/R4/SGB40iCQr8tR5Jiw== +"@next/swc-win32-arm64-msvc@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.1.tgz#83c639ee969cee36ce247c3abd1d9df97b5ecade" + integrity sha512-hswVFYQYIeGHE2JYaBVtvqmBQ1CppplQbZJS/JgrVI3x2CurNhEkmds/yqvDONfwfbttTtH4+q9Dzf/WVl3Opw== -"@next/swc-win32-ia32-msvc@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.5.tgz#4f5f7ba0a98ff89a883625d4af0125baed8b2e19" - integrity sha512-hGLc0ZRAwnaPL4ulwpp4D2RxmkHQLuI8CFOEEHdzZpS63/hMVzv81g8jzYA0UXbb9pus/iTc3VRbVbAM03SRrw== +"@next/swc-win32-ia32-msvc@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.1.tgz#52995748b92aa8ad053440301bc2c0d9fbcf27c2" + integrity sha512-Kny5JBehkTbKPmqulr5i+iKntO5YMP+bVM8Hf8UAmjSMVo3wehyLVc9IZkNmcbxi+vwETnQvJaT5ynYBkJ9dWA== -"@next/swc-win32-x64-msvc@12.2.5": - version "12.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.5.tgz#20fed129b04a0d3f632c6d0de135345bb623b1e4" - integrity sha512-7h5/ahY7NeaO2xygqVrSG/Y8Vs4cdjxIjowTZ5W6CKoTKn7tmnuxlUc2h74x06FKmbhAd9agOjr/AOKyxYYm9Q== +"@next/swc-win32-x64-msvc@12.3.1": + version "12.3.1" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136" + integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA== -"@nivo/annotations@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.74.0.tgz#f4a3474fdf8812c3812c30e08d3277e209bec0f6" - integrity sha512-nxZLKDi9YEy2zZUsOtbYL/2oAgsxK5SVZ1P3Csll+cQ96uLU6sU7jmb67AwK0nDbYk7BD3sZf/O/A9r/MCK4Ow== +"@nivo/annotations@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.80.0.tgz#127e4801fff7370dcfb9acfe1e335781dd65cfd5" + integrity sha512-bC9z0CLjU07LULTMWsqpjovRtHxP7n8oJjqBQBLmHOGB4IfiLbrryBfu9+aEZH3VN2jXHhdpWUz+HxeZzOzsLg== dependencies: - "@nivo/colors" "0.74.0" - "@react-spring/web" "9.2.6" + "@nivo/colors" "0.80.0" + "@react-spring/web" "9.4.5" lodash "^4.17.21" -"@nivo/axes@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.74.0.tgz#cf7cf2277b7aca5449a040ddf3e0cf9891971199" - integrity sha512-27o1H+Br0AaeUTiRhy7OebqzYEWr1xznHOxd+Hn2Xz9kK1alGBiPgwXrkXV0Q9CtrsroQFnX2QR3JxRgOtC5fA== +"@nivo/axes@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.80.0.tgz#22788855ddc45bb6a619dcd03d62d4bd8c0fc35f" + integrity sha512-AsUyaSHGwQVSEK8QXpsn8X+poZxvakLMYW7crKY1xTGPNw+SU4SSBohPVumm2jMH3fTSLNxLhAjWo71GBJXfdA== dependencies: - "@nivo/scales" "0.74.0" - "@react-spring/web" "9.2.6" + "@nivo/scales" "0.80.0" + "@react-spring/web" "9.4.5" d3-format "^1.4.4" d3-time-format "^3.0.0" -"@nivo/colors@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.74.0.tgz#29d1e7c6f3bcab4e872a168651b3a90cfba03a4f" - integrity sha512-5ClckmBm3x2XdJqHMylr6erY+scEL/twoGVfyXak/L+AIhL+Gf9PQxyxyfl3Lbtc3SPeAQe0ZAO1+VrmTn7qlA== +"@nivo/colors@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.80.0.tgz#5b70b4979df246d9d0d69fb638bba9764dd88b52" + integrity sha512-T695Zr411FU4RPo7WDINOAn8f79DPP10SFJmDdEqELE+cbzYVTpXqLGZ7JMv88ko7EOf9qxLQgcBqY69rp9tHQ== dependencies: d3-color "^2.0.0" d3-scale "^3.2.3" d3-scale-chromatic "^2.0.0" lodash "^4.17.21" - react-motion "^0.5.2" -"@nivo/core@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.74.0.tgz#7634c78a36a8bd50a0c04c6b6f12b779a88ec2f4" - integrity sha512-LZ3kN1PiEW0KU4PTBgaHFO757amyKZkEL4mKdAzvyNQtpq5idB3OhC/sYrBxhJaLqYcX19MgNfhIel/0KygHAg== +"@nivo/core@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.80.0.tgz#d180cb2622158eb7bc5f984131ff07984f12297e" + integrity sha512-6caih0RavXdWWSfde+rC2pk17WrX9YQlqK26BrxIdXzv3Ydzlh5SkrC7dR2TEvMGBhunzVeLOfiC2DWT1S8CFg== dependencies: - "@nivo/recompose" "0.74.0" - "@react-spring/web" "9.2.6" + "@nivo/recompose" "0.80.0" + "@react-spring/web" "9.4.5" d3-color "^2.0.0" d3-format "^1.4.4" - d3-hierarchy "^1.1.8" d3-interpolate "^2.0.1" d3-scale "^3.2.3" d3-scale-chromatic "^2.0.0" d3-shape "^1.3.5" d3-time-format "^3.0.0" lodash "^4.17.21" - resize-observer-polyfill "^1.5.1" -"@nivo/legends@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.74.0.tgz#8e5e04b2a3f980c2073a394d94c4d89fa8bc8724" - integrity sha512-Bfk392ngre1C8UaGoymwqK0acjjzuk0cglUSNsr0z8BAUQIVGUPthtfcxbq/yUYGJL/cxWky2QKxi9r3C0FbmA== +"@nivo/legends@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.80.0.tgz#49edc54000075b4df055f86794a8c32810269d06" + integrity sha512-h0IUIPGygpbKIZZZWIxkkxOw4SO0rqPrqDrykjaoQz4CvL4HtLIUS3YRA4akKOVNZfS5agmImjzvIe0s3RvqlQ== -"@nivo/line@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.74.0.tgz#f1f430d64a81d2fe1a5fd49e5cfaa61242066927" - integrity sha512-uJssLII1UTfxrZkPrkki054LFUpSKeqS35ttwK6VLvyqs5r3SrSXn223vDRNaaxuop5oT/L3APUJQwQDqUcj3w== +"@nivo/line@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.80.0.tgz#ba541b0fcfd53b3a7ce865feb43c993b7cf4a7d4" + integrity sha512-6UAD/y74qq3DDRnVb+QUPvXYojxMtwXMipGSNvCGk8omv1QZNTaUrbV+eQacvn9yh//a0yZcWipnpq0tGJyJCA== dependencies: - "@nivo/annotations" "0.74.0" - "@nivo/axes" "0.74.0" - "@nivo/colors" "0.74.0" - "@nivo/legends" "0.74.0" - "@nivo/scales" "0.74.0" - "@nivo/tooltip" "0.74.0" - "@nivo/voronoi" "0.74.0" - "@react-spring/web" "9.2.6" + "@nivo/annotations" "0.80.0" + "@nivo/axes" "0.80.0" + "@nivo/colors" "0.80.0" + "@nivo/legends" "0.80.0" + "@nivo/scales" "0.80.0" + "@nivo/tooltip" "0.80.0" + "@nivo/voronoi" "0.80.0" + "@react-spring/web" "9.4.5" d3-shape "^1.3.5" -"@nivo/recompose@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.74.0.tgz#057e8e1154073d7f4cb01aa8d165c3914b8bdb54" - integrity sha512-qC9gzGvDIxocrJoozDjqqffOwDpuEZijeMV59KExnztCwIpQbIYVBsDdpvL+tXfWausigSlnGILGfereXJTLUQ== +"@nivo/recompose@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.80.0.tgz#572048aed793321a0bada1fd176b72df5a25282e" + integrity sha512-iL3g7j3nJGD9+mRDbwNwt/IXDXH6E29mhShY1I7SP91xrfusZV9pSFf4EzyYgruNJk/2iqMuaqn+e+TVFra44A== dependencies: react-lifecycles-compat "^3.0.4" -"@nivo/scales@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.74.0.tgz#ede12b899da9e3aee7921ebce40f227e670a430d" - integrity sha512-5mER71NgZGdgs8X2PgilBpAWMMGtTXrUuYOBQWDKDMgtc83MU+mphhiYfLv5e6ViZyUB5ebfEkfeIgStLqrcEA== +"@nivo/scales@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.80.0.tgz#39313fb97c8ae9633c2aa1e17adb57cb851e8a50" + integrity sha512-4y2pQdCg+f3n4TKXC2tYuq71veZM+xPRQbOTgGYJpuBvMc7pQsXF9T5z7ryeIG9hkpXkrlyjecU6XcAG7tLSNg== dependencies: d3-scale "^3.2.3" d3-time "^1.0.11" d3-time-format "^3.0.0" lodash "^4.17.21" -"@nivo/tooltip@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.74.0.tgz#60d94b0fecc2fc179ada3efa380e7e456982b4a5" - integrity sha512-h3PUgNFF5HUeQFfx19MWS1uGK8iUDymZNY+5PyaCWDFT+0/ldXBu8uw5WYRui2KwNdTym6F0E/aT7JKczDd85w== +"@nivo/tooltip@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.80.0.tgz#07ebef47eb708a0612bd6297d5ad156bbec19d34" + integrity sha512-qGmrreRwnCsYjn/LAuwBtxBn/tvG8y+rwgd4gkANLBAoXd3bzJyvmkSe+QJPhUG64bq57ibDK+lO2pC48a3/fw== dependencies: - "@react-spring/web" "9.2.6" + "@react-spring/web" "9.4.5" -"@nivo/voronoi@0.74.0": - version "0.74.0" - resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.74.0.tgz#4b427955ddabd86934a2bbb95a62ff53ee97c575" - integrity sha512-Q3267T1+Tlufn8LbmSYnO8x9gL+h/iwH2Uqc5CENHSZu2KPD0PB82vxpQnbDVhjadulI0rlrPA9fU3VY3q1zKg== +"@nivo/voronoi@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.80.0.tgz#59cc7ed253dc1a5bbcf614a5ac37d2468d561599" + integrity sha512-zaJV3I3cRu1gHpsXCIEvp6GGlGY8P7D9CwAVCjYDGrz3W/+GKN0kA7qGyHTC97zVxJtfefxSPlP/GtOdxac+qw== dependencies: d3-delaunay "^5.3.0" d3-scale "^3.2.3" @@ -2747,50 +2744,51 @@ resolved "https://registry.yarnpkg.com/@react-query-firebase/firestore/-/firestore-0.4.2.tgz#6ae52768715aa0a5c0d903dd4fd953ed417ba635" integrity sha512-7eYp905+sfBRcBTdj7W7BAc3bI3V0D0kKca4/juOTnN4gyoNyaCNOCjLPY467dTq325hGs7BX0ol7Pw3JENdHA== -"@react-spring/animated@~9.2.6-beta.0": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.2.6.tgz#58f30fb75d8bfb7ccbc156cfd6b974a8f3dfd54e" - integrity sha512-xjL6nmixYNDvnpTs1FFMsMfSC0tURwPCU3b2jWNriYGLfwZ7c/TcyaEZA7yiNnmdFnuR3f3Z27AqIgaFC083Cw== +"@react-spring/animated@~9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.5.tgz#dd9921c716a4f4a3ed29491e0c0c9f8ca0eb1a54" + integrity sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA== dependencies: - "@react-spring/shared" "~9.2.6-beta.0" - "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/shared" "~9.4.5" + "@react-spring/types" "~9.4.5" -"@react-spring/core@~9.2.6-beta.0": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.2.6.tgz#ae22338fe55d070caf03abb4293b5519ba620d93" - integrity sha512-uPHUxmu+w6mHJrfQTMtmGJ8iZEwiVxz9kH7dRyk69bkZJt9z+w0Oj3UF4J3VcECZsbm3HRhN2ogXSAaqGjwhQw== +"@react-spring/core@~9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.4.5.tgz#4616e1adc18dd10f5731f100ebdbe9518b89ba3c" + integrity sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ== dependencies: - "@react-spring/animated" "~9.2.6-beta.0" - "@react-spring/shared" "~9.2.6-beta.0" - "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/animated" "~9.4.5" + "@react-spring/rafz" "~9.4.5" + "@react-spring/shared" "~9.4.5" + "@react-spring/types" "~9.4.5" -"@react-spring/rafz@~9.2.6-beta.0": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.2.6.tgz#d97484003875bf5fb5e6ec22dee97cc208363e48" - integrity sha512-62SivLKEpo7EfHPkxO5J3g9Cr9LF6+1A1RVOMJhkcpEYtbdbmma/d63Xp8qpMPEpk7uuWxaTb6jjyxW33pW3sg== +"@react-spring/rafz@~9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.4.5.tgz#84f809f287f2a66bbfbc66195db340482f886bd7" + integrity sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ== -"@react-spring/shared@~9.2.6-beta.0": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.2.6.tgz#2c84e62cc0cfbbbbeb5546acd46c1f4b248bc562" - integrity sha512-Qrm9fopKG/RxZ3Rw+4euhrpnB3uXSyiON9skHbcBfmkkzagpkUR66MX1YLrhHw0UchcZuSDnXs0Lonzt1rpWag== +"@react-spring/shared@~9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.4.5.tgz#4c3ad817bca547984fb1539204d752a412a6d829" + integrity sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA== dependencies: - "@react-spring/rafz" "~9.2.6-beta.0" - "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/rafz" "~9.4.5" + "@react-spring/types" "~9.4.5" -"@react-spring/types@~9.2.6-beta.0": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.2.6.tgz#f60722fcf9f8492ae16d0bdc47f0ea3c2a16d2cf" - integrity sha512-l7mCw182DtDMnCI8CB9orgTAEoFZRtdQ6aS6YeEAqYcy3nQZPmPggIHH9DxyLw7n7vBPRSzu9gCvUMgXKpTflg== +"@react-spring/types@~9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.4.5.tgz#9c71e5ff866b5484a7ef3db822bf6c10e77bdd8c" + integrity sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg== -"@react-spring/web@9.2.6": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.2.6.tgz#c4fba69e1b1b43bd1d6a62346530cfb07f2be09b" - integrity sha512-0HkRsEYR/CO3Uw46FWDWaF2wg2rUXcWE2R9AoZXthEYLUn5w9uE1mf2Jel7BxBxWGQ73owkqSQv+klA1Hb+ViQ== +"@react-spring/web@9.4.5": + version "9.4.5" + resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.4.5.tgz#b92f05b87cdc0963a59ee149e677dcaff09f680e" + integrity sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA== dependencies: - "@react-spring/animated" "~9.2.6-beta.0" - "@react-spring/core" "~9.2.6-beta.0" - "@react-spring/shared" "~9.2.6-beta.0" - "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/animated" "~9.4.5" + "@react-spring/core" "~9.4.5" + "@react-spring/shared" "~9.4.5" + "@react-spring/types" "~9.4.5" "@rushstack/eslint-patch@^1.1.3": version "1.1.3" @@ -2933,10 +2931,10 @@ "@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-svgo" "^6.2.0" -"@swc/helpers@0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.3.tgz#16593dfc248c53b699d4b5026040f88ddb497012" - integrity sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA== +"@swc/helpers@0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de" + integrity sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw== dependencies: tslib "^2.4.0" @@ -3566,6 +3564,13 @@ dependencies: "@types/node" "*" +"@types/yauzl@^2.9.1": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" + integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@5.36.0": version "5.36.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5" @@ -4318,7 +4323,7 @@ base16@^1.0.0: resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" integrity sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ== -base64-js@^1.3.0: +base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -4348,6 +4353,15 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.7.1: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -4461,6 +4475,11 @@ browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4 node-releases "^2.0.3" picocolors "^1.0.0" +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + buffer-equal-constant-time@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -4471,6 +4490,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.2.1, buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -4545,6 +4572,11 @@ caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498" integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== +caniuse-lite@^1.0.30001406: + version "1.0.30001409" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001409.tgz#6135da9dcab34cd9761d9cdb12a68e6740c5e96e" + integrity sha512-V0mnJ5dwarmhYv8/MzhJ//aW68UpvnQBXv8lJ2QUsvn2pHcmAuNtu8hQEDz37XnA1iE+lRR9CIfGWWpgJ5QedQ== + ccount@^1.0.0, ccount@^1.0.3: version "1.1.0" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" @@ -4645,6 +4677,11 @@ chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + chrome-trace-event@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" @@ -5011,7 +5048,7 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" -cross-fetch@^3.1.5: +cross-fetch@3.1.5, cross-fetch@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== @@ -5227,11 +5264,6 @@ d3-format@^1.4.4: resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== -d3-hierarchy@^1.1.8: - version "1.1.9" - resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83" - integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ== - "d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" @@ -5338,7 +5370,7 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -5494,6 +5526,11 @@ detective@^5.2.1: defined "^1.0.0" minimist "^1.2.6" +devtools-protocol@0.0.1036444: + version "0.0.1036444" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1036444.tgz#a570d3cdde61527c82f9b03919847b8ac7b1c2b9" + integrity sha512-0y4f/T8H9lsESV9kKP1HDUXgHxCdniFeJh6Erq+FbdOEvp/Ydp9t8kcAAM5gOd17pMrTDlFWntoHtzzeTUWKNw== + dicer@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.1.tgz#abf28921e3475bc5e801e74e0159fd94f927ba97" @@ -6234,6 +6271,17 @@ extend@^3.0.0, extend@^3.0.2, extend@~3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6316,6 +6364,13 @@ fbjs@^3.0.0, fbjs@^3.0.1: setimmediate "^1.0.5" ua-parser-js "^0.7.30" +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== + dependencies: + pend "~1.2.0" + feed@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" @@ -6575,6 +6630,11 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@^10.0.1: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -7293,6 +7353,14 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + https-proxy-agent@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" @@ -7301,14 +7369,6 @@ https-proxy-agent@^3.0.0: agent-base "^4.3.0" debug "^3.1.0" -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -7331,6 +7391,11 @@ idb@7.0.1: resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7" integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg== +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore-by-default@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" @@ -7404,7 +7469,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -8559,6 +8624,11 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" @@ -8637,31 +8707,31 @@ next-sitemap@^2.5.14: "@corex/deepmerge" "^2.6.148" minimist "^1.2.6" -next@12.2.5: - version "12.2.5" - resolved "https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717" - integrity sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA== +next@12.3.1: + version "12.3.1" + resolved "https://registry.yarnpkg.com/next/-/next-12.3.1.tgz#127b825ad2207faf869b33393ec8c75fe61e50f1" + integrity sha512-l7bvmSeIwX5lp07WtIiP9u2ytZMv7jIeB8iacR28PuUEFG5j0HGAPnMqyG5kbZNBG2H7tRsrQ4HCjuMOPnANZw== dependencies: - "@next/env" "12.2.5" - "@swc/helpers" "0.4.3" - caniuse-lite "^1.0.30001332" + "@next/env" "12.3.1" + "@swc/helpers" "0.4.11" + caniuse-lite "^1.0.30001406" postcss "8.4.14" - styled-jsx "5.0.4" + styled-jsx "5.0.7" use-sync-external-store "1.2.0" optionalDependencies: - "@next/swc-android-arm-eabi" "12.2.5" - "@next/swc-android-arm64" "12.2.5" - "@next/swc-darwin-arm64" "12.2.5" - "@next/swc-darwin-x64" "12.2.5" - "@next/swc-freebsd-x64" "12.2.5" - "@next/swc-linux-arm-gnueabihf" "12.2.5" - "@next/swc-linux-arm64-gnu" "12.2.5" - "@next/swc-linux-arm64-musl" "12.2.5" - "@next/swc-linux-x64-gnu" "12.2.5" - "@next/swc-linux-x64-musl" "12.2.5" - "@next/swc-win32-arm64-msvc" "12.2.5" - "@next/swc-win32-ia32-msvc" "12.2.5" - "@next/swc-win32-x64-msvc" "12.2.5" + "@next/swc-android-arm-eabi" "12.3.1" + "@next/swc-android-arm64" "12.3.1" + "@next/swc-darwin-arm64" "12.3.1" + "@next/swc-darwin-x64" "12.3.1" + "@next/swc-freebsd-x64" "12.3.1" + "@next/swc-linux-arm-gnueabihf" "12.3.1" + "@next/swc-linux-arm64-gnu" "12.3.1" + "@next/swc-linux-arm64-musl" "12.3.1" + "@next/swc-linux-x64-gnu" "12.3.1" + "@next/swc-linux-x64-musl" "12.3.1" + "@next/swc-win32-arm64-msvc" "12.3.1" + "@next/swc-win32-ia32-msvc" "12.3.1" + "@next/swc-win32-x64-msvc" "12.3.1" no-case@^3.0.4: version "3.0.4" @@ -9202,15 +9272,10 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -performance-now@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" - integrity sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU= - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== picocolors@^1.0.0: version "1.0.0" @@ -9634,6 +9699,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +progress@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -9656,7 +9726,7 @@ prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -9824,7 +9894,7 @@ proxy-agent@^3.0.3: proxy-from-env "^1.0.0" socks-proxy-agent "^4.0.1" -proxy-from-env@^1.0.0: +proxy-from-env@1.1.0, proxy-from-env@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -9873,6 +9943,23 @@ pupa@^2.1.1: dependencies: escape-goat "^2.0.0" +puppeteer@18.0.5: + version "18.0.5" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-18.0.5.tgz#873223b17b92345182c5b5e8cfbd6f3117f1547d" + integrity sha512-s4erjxU0VtKojPvF+KvLKG6OHUPw7gO2YV1dtOsoryyCbhrs444fXb4QZqGWuTv3V/rgSCUzeixxu34g0ZkSMA== + dependencies: + cross-fetch "3.1.5" + debug "4.3.4" + devtools-protocol "0.0.1036444" + extract-zip "2.0.1" + https-proxy-agent "5.0.1" + progress "2.0.3" + proxy-from-env "1.1.0" + rimraf "3.0.2" + tar-fs "2.1.1" + unbzip2-stream "1.4.3" + ws "8.8.1" + pure-color@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" @@ -9917,13 +10004,6 @@ raf-schd@^4.0.2: resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== -raf@^3.1.0: - version "3.4.1" - resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" - integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== - dependencies: - performance-now "^2.1.0" - randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -10123,15 +10203,6 @@ react-masonry-css@1.0.16: resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== -react-motion@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" - integrity sha512-9q3YAvHoUiWlP3cK0v+w1N5Z23HXMj4IF4YuvjvWegWqNPfLXsOBE/V7UvQGpXxHFKRQQcNcVQE31g9SB/6qgQ== - dependencies: - performance-now "^0.2.0" - prop-types "^15.5.8" - raf "^3.1.0" - react-query@3.39.0: version "3.39.0" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.0.tgz#0caca7b0da98e65008bbcd4df0d25618c2100050" @@ -10269,7 +10340,7 @@ readable-stream@2, readable-stream@^2.0.1, readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -10533,11 +10604,6 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= -resize-observer-polyfill@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -11267,10 +11333,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -styled-jsx@5.0.4: - version "5.0.4" - resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.4.tgz#5b1bd0b9ab44caae3dd1361295559706e044aa53" - integrity sha512-sDFWLbg4zR+UkNzfk5lPilyIgtpddfxXEULxhujorr5jtePTUqiPDc5BC0v1NRqTr/WaFBGQQUoYToGlF4B2KQ== +styled-jsx@5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.7.tgz#be44afc53771b983769ac654d355ca8d019dff48" + integrity sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA== stylehacks@^5.1.0: version "5.1.0" @@ -11362,6 +11428,27 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar-fs@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + teeny-request@^7.1.3: version "7.2.0" resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" @@ -11399,6 +11486,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + thunkify@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d" @@ -11622,6 +11714,14 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbzip2-stream@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + undefsafe@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" @@ -12229,6 +12329,11 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +ws@8.8.1: + version "8.8.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0" + integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA== + ws@>=7.4.6: version "8.6.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.6.0.tgz#e5e9f1d9e7ff88083d0c0dd8281ea662a42c9c23" @@ -12309,6 +12414,14 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"