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/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/firestore.indexes.json b/firestore.indexes.json index bcee41d5..2b7ef839 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -100,6 +100,20 @@ } ] }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", 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-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/index.ts b/functions/src/index.ts index 336cb4ad..4844cea8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -51,7 +51,7 @@ export * from './resolve-market' export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' -export * from './test-scheduled-function' +export * from './close-market' import { health } from './health' import { transact } from './transact' @@ -68,13 +68,13 @@ 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' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' -import { testscheduledfunction } from './test-scheduled-function' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -94,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) @@ -101,7 +102,6 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) -const testScheduledFunction = toCloudFunction(testscheduledfunction) export { healthFunction as health, @@ -119,6 +119,7 @@ export { withdrawLiquidityFunction as withdrawliquidity, createGroupFunction as creategroup, resolveMarketFunction as resolvemarket, + closeMarketFunction as closemarket, unsubscribeFunction as unsubscribe, stripeWebhookFunction as stripewebhook, createCheckoutSessionFunction as createcheckoutsession, @@ -126,5 +127,4 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, - testScheduledFunction as testscheduledfunction, } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 101b085c..d1f0a503 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -22,6 +22,60 @@ import { addUserToContractFollowers } from './follow-market' const firestore = admin.firestore() +function getMostRecentCommentableBet( + before: number, + betsByCurrentUser: Bet[], + commentsByCurrentUser: ContractComment[], + answerOutcome?: string +) { + let sortedBetsByCurrentUser = betsByCurrentUser.sort( + (a, b) => b.createdTime - a.createdTime + ) + if (answerOutcome) { + sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1) + } + return sortedBetsByCurrentUser + .filter((bet) => { + const { createdTime, isRedemption } = bet + // You can comment on bets posted in the last hour + const commentable = !isRedemption && before - createdTime < 60 * 60 * 1000 + const alreadyCommented = commentsByCurrentUser.some( + (comment) => comment.createdTime > bet.createdTime + ) + if (commentable && !alreadyCommented) { + if (!answerOutcome) return true + return answerOutcome === bet.outcome + } + return false + }) + .pop() +} + +async function getPriorUserComments( + contractId: string, + userId: string, + before: number +) { + const priorCommentsQuery = await firestore + .collection('contracts') + .doc(contractId) + .collection('comments') + .where('createdTime', '<', before) + .where('userId', '==', userId) + .get() + return priorCommentsQuery.docs.map((d) => d.data() as ContractComment) +} + +async function getPriorContractBets(contractId: string, before: number) { + const priorBetsQuery = await firestore + .collection('contracts') + .doc(contractId) + .collection('bets') + .where('createdTime', '<', before) + .get() + return priorBetsQuery.docs.map((d) => d.data() as Bet) +} + export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') @@ -55,17 +109,33 @@ export const onCreateCommentOnContract = functions .doc(contract.id) .update({ lastCommentTime, lastUpdatedTime: Date.now() }) - const previousBetsQuery = await firestore - .collection('contracts') - .doc(contractId) - .collection('bets') - .where('createdTime', '<', comment.createdTime) - .get() - const previousBets = previousBetsQuery.docs.map((d) => d.data() as Bet) - const position = getLargestPosition( - contract, - previousBets.filter((b) => b.userId === comment.userId && !b.isAnte) + const priorBets = await getPriorContractBets( + contractId, + comment.createdTime ) + const priorUserBets = priorBets.filter( + (b) => b.userId === comment.userId && !b.isAnte + ) + const priorUserComments = await getPriorUserComments( + contractId, + comment.userId, + comment.createdTime + ) + const bet = getMostRecentCommentableBet( + comment.createdTime, + priorUserBets, + priorUserComments, + comment.answerOutcome + ) + if (bet) { + await change.ref.update({ + betId: bet.id, + betOutcome: bet.outcome, + betAmount: bet.amount, + }) + } + + const position = getLargestPosition(contract, priorUserBets) if (position) { const fields: { [k: string]: unknown } = { commenterPositionShares: position.shares, @@ -73,7 +143,7 @@ export const onCreateCommentOnContract = functions } const previousProb = contract.outcomeType === 'BINARY' - ? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter + ? maxBy(priorBets, (bet) => bet.createdTime)?.probAfter : undefined if (previousProb != null) { fields.commenterPositionProb = previousProb @@ -81,7 +151,6 @@ export const onCreateCommentOnContract = functions await change.ref.update(fields) } - let bet: Bet | undefined let answer: Answer | undefined if (comment.answerOutcome) { answer = @@ -90,23 +159,6 @@ export const onCreateCommentOnContract = functions (answer) => answer.id === comment.answerOutcome ) : undefined - } else if (comment.betId) { - const betSnapshot = await firestore - .collection('contracts') - .doc(contractId) - .collection('bets') - .doc(comment.betId) - .get() - bet = betSnapshot.data() as Bet - answer = - contract.outcomeType === 'FREE_RESPONSE' && contract.answers - ? contract.answers.find((answer) => answer.id === bet?.outcome) - : undefined - - await change.ref.update({ - betOutcome: bet.outcome, - betAmount: bet.amount, - }) } const comments = await getValues( diff --git a/functions/src/on-create-user.ts b/functions/src/on-create-user.ts index f23d2f5b..71425f2c 100644 --- a/functions/src/on-create-user.ts +++ b/functions/src/on-create-user.ts @@ -4,8 +4,14 @@ import * as utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { getPrivateUser } from './utils' -import { User } from '../../common/user' -import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails' +import { User } from 'common/user' +import { + sendCreatorGuideEmail, + sendInterestingMarketsEmail, + sendPersonalFollowupEmail, + sendWelcomeEmail, +} from './emails' +import { getTrendingContracts } from './weekly-markets-emails' export const onCreateUser = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -19,4 +25,21 @@ export const onCreateUser = functions const followupSendTime = dayjs().add(48, 'hours').toString() await sendPersonalFollowupEmail(user, privateUser, followupSendTime) + + const guideSendTime = dayjs().add(96, 'hours').toString() + await sendCreatorGuideEmail(user, privateUser, guideSendTime) + + // skip email if weekly email is about to go out + const day = dayjs().utc().day() + if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return + + const contracts = await getTrendingContracts() + const marketsSendTime = dayjs().add(24, 'hours').toString() + + await sendInterestingMarketsEmail( + user, + privateUser, + contracts, + marketsSendTime + ) }) diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts index 57976ff2..52ef39d4 100644 --- a/functions/src/score-contracts.ts +++ b/functions/src/score-contracts.ts @@ -1,12 +1,14 @@ 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' -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() }) @@ -44,11 +46,22 @@ async function scoreContractsInternal() { 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 + + let dailyScore: number | undefined + if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') { + 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/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/update-loans.ts b/functions/src/update-loans.ts index c35a0613..460398ef 100644 --- a/functions/src/update-loans.ts +++ b/functions/src/update-loans.ts @@ -12,7 +12,7 @@ import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() export const updateLoans = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .runWith({ memory: '8GB', timeoutSeconds: 540 }) // Run every day at midnight. .pubsub.schedule('0 0 * * *') .timeZone('America/Los_Angeles') diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 99f6ff1d..d2b5f9b2 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -22,7 +22,7 @@ import { Group } from 'common/group' const firestore = admin.firestore() export const updateMetrics = functions - .runWith({ memory: '4GB', timeoutSeconds: 540 }) + .runWith({ memory: '8GB', timeoutSeconds: 540 }) .pubsub.schedule('every 15 minutes') .onRun(updateMetricsCore) diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index 1434c4f2..6f410886 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -343,6 +343,6 @@ export const updateStatsCore = async () => { } export const updateStats = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .runWith({ memory: '4GB', timeoutSeconds: 540 }) .pubsub.schedule('every 60 minutes') .onRun(updateStatsCore) 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/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 41b7f0f9..51cf5799 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,4 +1,4 @@ -import { sortBy, partition, sum, uniq } from 'lodash' +import { sortBy, partition, sum } from 'lodash' import { useEffect, useState } from 'react' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' @@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' import clsx from 'clsx' @@ -39,22 +38,14 @@ export function AnswersPanel(props: { const answers = (useAnswers(contract.id) ?? contract.answers).filter( (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' ) - const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) - - const [winningAnswers, losingAnswers] = partition( - answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), - (answer) => - answer.id === resolution || (resolutions && resolutions[answer.id]) + const [winningAnswers, notWinningAnswers] = partition( + answers, + (a) => a.id === resolution || (resolutions && resolutions[a.id]) + ) + const [visibleAnswers, invisibleAnswers] = partition( + sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)), + (a) => showAllAnswers || totalBets[a.id] > 0 ) - const sortedAnswers = [ - ...sortBy(winningAnswers, (answer) => - resolutions ? -1 * resolutions[answer.id] : 0 - ), - ...sortBy( - resolution ? [] : losingAnswers, - (answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id) - ), - ] const user = useUser() @@ -67,12 +58,6 @@ export function AnswersPanel(props: { const chosenTotal = sum(Object.values(chosenAnswers)) - const answerItems = getAnswerItems( - contract, - losingAnswers.length > 0 ? losingAnswers : sortedAnswers, - user - ) - const onChoose = (answerId: string, prob: number) => { if (resolveOption === 'CHOOSE') { setChosenAnswers({ [answerId]: prob }) @@ -109,13 +94,13 @@ export function AnswersPanel(props: { return ( {(resolveOption || resolution) && - sortedAnswers.map((answer) => ( + sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => ( -
- {answerItems.map((item) => ( -
-
- -
-
- ))} - - {hasZeroBetAnswers && !showAllAnswers && ( - - )} - -
-
+ + {visibleAnswers.map((a) => ( + + ))} + {invisibleAnswers.length > 0 && !showAllAnswers && ( + + )} + )} - {answers.length <= 1 && ( + {answers.length === 0 && (
No answers yet...
)} @@ -175,35 +158,9 @@ export function AnswersPanel(props: { ) } -function getAnswerItems( - contract: FreeResponseContract | MultipleChoiceContract, - answers: Answer[], - user: User | undefined | null -) { - let outcomes = uniq(answers.map((answer) => answer.number.toString())) - outcomes = sortBy(outcomes, (outcome) => - getOutcomeProbability(contract, outcome) - ).reverse() - - return outcomes - .map((outcome) => { - const answer = answers.find((answer) => answer.id === outcome) as Answer - //unnecessary - return { - id: outcome, - type: 'answer' as const, - contract, - answer, - user, - } - }) - .filter((group) => group.answer) -} - function OpenAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract answer: Answer - type: string }) { const { answer, contract } = props const { username, avatarUrl, name, text } = answer @@ -212,7 +169,7 @@ function OpenAnswer(props: { const [open, setOpen] = useState(false) return ( - + -
- -
+
answered
- - - - - -
- - {probPercent} - - setOpen(true)} - /> -
+ + + + {probPercent} + + setOpen(true)} + /> 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/comment-input.tsx b/web/components/comment-input.tsx index ca1f4a96..3ba6f2ce 100644 --- a/web/components/comment-input.tsx +++ b/web/components/comment-input.tsx @@ -11,22 +11,16 @@ 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 parentCommentId?: string - onSubmitComment?: (editor: Editor, betId: string | undefined) => void + onSubmitComment?: (editor: Editor) => void className?: string - presetId?: string }) { - const { - parentAnswerOutcome, - parentCommentId, - replyToUser, - onSubmitComment, - presetId, - } = props + const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } = + props const user = useUser() const { editor, upload } = useTextEditor({ @@ -40,10 +34,10 @@ export function CommentInput(props: { const [isSubmitting, setIsSubmitting] = useState(false) - async function submitComment(betId: string | undefined) { + async function submitComment() { if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) - onSubmitComment?.(editor, betId) + onSubmitComment?.(editor) setIsSubmitting(false) } @@ -61,11 +55,10 @@ export function CommentInput(props: {
@@ -74,28 +67,19 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - replyToUser?: { id: string; username: string } + replyTo?: { id: string; username: string } editor: Editor | null upload: Parameters[0]['upload'] - submitComment: (id?: string) => void + submitComment: () => void isSubmitting: boolean - presetId?: string }) { - const { - user, - editor, - upload, - submitComment, - presetId, - isSubmitting, - replyToUser, - } = props + const { user, editor, upload, submitComment, isSubmitting, replyTo } = props useEffect(() => { editor?.setEditable(!isSubmitting) }, [isSubmitting, editor]) const submit = () => { - submitComment(presetId) + submitComment() editor?.commands?.clearContent() } @@ -123,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() @@ -142,7 +126,7 @@ export function CommentInputTextArea(props: { {user && !isSubmitting && ( diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 3d25dcdd..331dcb80 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -9,7 +9,14 @@ import { } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' -import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' +import { + useEffect, + useLayoutEffect, + useRef, + useMemo, + ReactNode, + useState, +} from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { @@ -32,22 +39,26 @@ import { searchClient, searchIndexName, } from 'web/lib/service/algolia' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { AdjustmentsIcon } from '@heroicons/react/solid' +import { Button } from './button' +import { Modal } from './layout/modal' +import { Title } from './title' export const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, - { label: `Most traded`, value: 'most-traded' }, + { label: 'Daily trending', value: 'daily-score' }, { label: '24h volume', value: '24-hour-vol' }, - { label: '24h change', value: 'prob-change-day' }, { label: 'Last updated', value: 'last-updated' }, - { label: 'Subsidy', value: 'liquidity' }, - { label: 'Close date', value: 'close-date' }, + { label: 'Closing soon', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, { label: 'Highest %', value: 'prob-descending' }, { label: 'Lowest %', value: 'prob-ascending' }, ] as const export type Sort = typeof SORTS[number]['value'] +export const PROB_SORTS = ['prob-descending', 'prob-ascending'] type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' @@ -78,11 +89,13 @@ export function ContractSearch(props: { hideGroupLink?: boolean hideQuickBet?: boolean noLinkAvatar?: boolean + showProbChange?: boolean } headerClassName?: string persistPrefix?: string useQueryUrlParam?: boolean isWholePage?: boolean + includeProbSorts?: boolean noControls?: boolean maxResults?: number renderContracts?: ( @@ -104,6 +117,7 @@ export function ContractSearch(props: { headerClassName, persistPrefix, useQueryUrlParam, + includeProbSorts, isWholePage, noControls, maxResults, @@ -116,6 +130,7 @@ export function ContractSearch(props: { numPages: 1, pages: [] as Contract[][], showTime: null as ShowTime | null, + showProbChange: false, }, !persistPrefix ? undefined @@ -169,8 +184,9 @@ export function ContractSearch(props: { const newPage = results.hits as any as Contract[] const showTime = sort === 'close-date' || sort === 'resolve-date' ? sort : null + const showProbChange = sort === 'daily-score' const pages = freshQuery ? [newPage] : [...state.pages, newPage] - setState({ numPages: results.nbPages, pages, showTime }) + setState({ numPages: results.nbPages, pages, showTime, showProbChange }) if (freshQuery && isWholePage) window.scrollTo(0, 0) } } @@ -188,6 +204,12 @@ export function ContractSearch(props: { }, 100) ).current + const updatedCardUIOptions = useMemo(() => { + if (cardUIOptions?.showProbChange === undefined && state.showProbChange) + return { ...cardUIOptions, showProbChange: true } + return cardUIOptions + }, [cardUIOptions, state.showProbChange]) + const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) @@ -209,6 +231,7 @@ export function ContractSearch(props: { persistPrefix={persistPrefix} hideOrderSelector={hideOrderSelector} useQueryUrlParam={useQueryUrlParam} + includeProbSorts={includeProbSorts} user={user} onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} @@ -223,7 +246,7 @@ export function ContractSearch(props: { showTime={state.showTime ?? undefined} onContractClick={onContractClick} highlightOptions={highlightOptions} - cardUIOptions={cardUIOptions} + cardUIOptions={updatedCardUIOptions} /> )} @@ -238,6 +261,7 @@ function ContractSearchControls(props: { additionalFilter?: AdditionalFilter persistPrefix?: string hideOrderSelector?: boolean + includeProbSorts?: boolean onSearchParametersChanged: (params: SearchParameters) => void useQueryUrlParam?: boolean user?: User | null @@ -257,6 +281,7 @@ function ContractSearchControls(props: { user, noControls, autoFocus, + includeProbSorts, } = props const router = useRouter() @@ -270,6 +295,8 @@ function ContractSearchControls(props: { } ) + const isMobile = useIsMobile() + const sortKey = `${persistPrefix}-search-sort` const savedSort = safeLocalStorage()?.getItem(sortKey) @@ -415,30 +442,33 @@ function ContractSearchControls(props: { className="input input-bordered w-full" autoFocus={autoFocus} /> - {!query && ( - + {!isMobile && ( + )} - {!hideOrderSelector && !query && ( - + {isMobile && ( + <> + + } + /> + )} @@ -481,3 +511,78 @@ function ContractSearchControls(props: { ) } + +export function SearchFilters(props: { + filter: string + selectFilter: (newFilter: filter) => void + hideOrderSelector: boolean | undefined + selectSort: (newSort: Sort) => void + sort: string + className?: string + includeProbSorts?: boolean +}) { + const { + filter, + selectFilter, + hideOrderSelector, + selectSort, + sort, + className, + includeProbSorts, + } = props + + const sorts = includeProbSorts + ? SORTS + : SORTS.filter((sort) => !PROB_SORTS.includes(sort.value)) + + return ( +
+ + {!hideOrderSelector && ( + + )} +
+ ) +} + +export function MobileSearchBar(props: { children: ReactNode }) { + const { children } = props + const [openFilters, setOpenFilters] = useState(false) + return ( + <> + + + + + {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 a863f1bf..f984e3b6 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -1,12 +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 { CommentTipMap } from 'web/hooks/use-tip-txns' -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' @@ -14,62 +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[] - tips: CommentTipMap -}) { - const { contract, bets, comments, tips } = 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 @@ -90,30 +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]} - tips={tips[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 b5895f60..17471796 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,23 +1,23 @@ +import { memo, useState } from 'react' +import { getOutcomeProbability } from 'common/calculate' +import { Pagination } from 'web/components/pagination' +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 { groupBy, sortBy } from 'lodash' import { Bet } from 'common/bet' -import { Contract, CPMMBinaryContract } from 'common/contract' -import { ContractComment } from 'common/comment' -import { PAST_BETS, User } from 'common/user' -import { - ContractCommentsActivity, - ContractBetsActivity, - FreeResponseContractCommentsActivity, -} from '../feed/contract-activity' +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 { tradingAllowed } from 'web/lib/firebase/contracts' -import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { LoadingIndicator } from 'web/components/loading-indicator' import { useComments } from 'web/hooks/use-comments' import { useLiquidity } from 'web/hooks/use-liquidity' -import { BetSignUpPrompt } from '../sign-up-prompt' -import { PlayMoneyDisclaimer } from '../play-money-disclaimer' -import BetButton from '../bet-button' +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,88 +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[] - tips: CommentTipMap -}) { - const { contract, user, bets, tips } = props - const { outcomeType } = contract +export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const isMobile = useIsMobile() - - const lps = useLiquidity(contract.id) - + 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 visibleLps = (lps ?? []).filter( - (l) => - !l.isAnte && - l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID && - l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID && - l.amount > 0 - ) - - // Load comments here, so the badge count will be correct - const updatedComments = useComments(contract.id) - const comments = updatedComments ?? props.comments - - const betActivity = lps != null && ( - <ContractBetsActivity - contract={contract} - bets={visibleBets} - lps={visibleLps} - /> - ) - - const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets - const generalComments = comments.filter( - (comment) => - comment.answerOutcome === undefined && - (outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true) - ) - - const commentActivity = - outcomeType === 'FREE_RESPONSE' ? ( - <> - <FreeResponseContractCommentsActivity - contract={contract} - betsByCurrentUser={ - user ? visibleBets.filter((b) => b.userId === user.id) : [] - } - comments={comments} - tips={tips} - user={user} - /> - <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} - betsByCurrentUser={ - user ? generalBets.filter((b) => b.userId === user.id) : [] - } - comments={generalComments} - tips={tips} - user={user} - /> - </Col> - </> - ) : ( - <ContractCommentsActivity - contract={contract} - betsByCurrentUser={ - user ? visibleBets.filter((b) => b.userId === user.id) : [] - } - comments={comments} - tips={tips} - user={user} - /> - ) const yourTrades = ( <div> @@ -123,44 +48,173 @@ export function ContractTabs(props: { ) return ( - <> - <Tabs - currentPageForAnalytics={'contract'} - tabs={[ - { - title: 'Comments', - content: commentActivity, - badge: `${comments.length}`, - }, - { - title: capitalize(PAST_BETS), - content: betActivity, - badge: `${visibleBets.length + visibleLps.length}`, - }, - ...(!user || !userBets?.length - ? [] - : [ - { - title: isMobile ? `You` : `Your ${PAST_BETS}`, - content: yourTrades, - }, - ]), - ]} - /> - {!user ? ( - <Col className="mt-4 max-w-sm items-center xl:hidden"> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </Col> - ) : ( - outcomeType === 'BINARY' && - tradingAllowed(contract) && ( - <BetButton - contract={contract as CPMMBinaryContract} - className="mb-2 !mt-0 xl:hidden" - /> - ) - )} - </> + <Tabs + className="mb-4" + currentPageForAnalytics={'contract'} + tabs={[ + { + title: 'Comments', + content: <CommentsTabContent contract={contract} />, + }, + { + title: capitalize(PAST_BETS), + content: <BetsTabContent contract={contract} bets={bets} />, + }, + ...(!user || !userBets?.length + ? [] + : [ + { + title: isMobile ? `You` : `Your ${PAST_BETS}`, + content: yourTrades, + }, + ]), + ]} + /> ) } + +const CommentsTabContent = memo(function CommentsTabContent(props: { + contract: Contract +}) { + const { contract } = props + const tips = useTipTxns({ contractId: contract.id }) + 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 ( + <> + {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" /> + <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 ( + <> + <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} + /> + ))} + </> + ) + } +}) + +const BetsTabContent = memo(function BetsTabContent(props: { + contract: Contract + bets: Bet[] +}) { + const { contract, bets } = props + const [page, setPage] = useState(0) + const ITEMS_PER_PAGE = 50 + const start = page * ITEMS_PER_PAGE + 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 && + l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID && + l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID && + l.amount > 0 + ) + + const items = [ + ...visibleBets.map((bet) => ({ + type: 'bet' as const, + id: bet.id + '-' + bet.isSold, + bet, + })), + ...visibleLps.map((lp) => ({ + type: 'liquidity' as const, + id: lp.id, + lp, + })), + ] + + const pageItems = sortBy(items, (item) => + item.type === 'bet' + ? -item.bet.createdTime + : item.type === 'liquidity' + ? -item.lp.createdTime + : undefined + ).slice(start, end) + + return ( + <> + <Col className="mb-4 gap-4"> + {pageItems.map((item) => + item.type === 'bet' ? ( + <FeedBet key={item.id} contract={contract} bet={item.bet} /> + ) : ( + <FeedLiquidity key={item.id} liquidity={item.lp} /> + ) + )} + </Col> + <Pagination + page={page} + itemsPerPage={50} + totalItems={items.length} + setPage={setPage} + scrollToTop + nextTitle={'Older'} + prevTitle={'Newer'} + /> + </> + ) +}) diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 3da9a5d5..0b93148d 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 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/contract-activity.tsx b/web/components/feed/contract-activity.tsx deleted file mode 100644 index e660bf10..00000000 --- a/web/components/feed/contract-activity.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react' -import { Contract, FreeResponseContract } from 'common/contract' -import { ContractComment } from 'common/comment' -import { Bet } from 'common/bet' -import { getOutcomeProbability } from 'common/calculate' -import { Pagination } from 'web/components/pagination' -import { FeedBet } from './feed-bets' -import { FeedLiquidity } from './feed-liquidity' -import { FeedAnswerCommentGroup } from './feed-answer-comment-group' -import { FeedCommentThread, ContractCommentInput } from './feed-comments' -import { User } from 'common/user' -import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { LiquidityProvision } from 'common/liquidity-provision' -import { groupBy, sortBy } from 'lodash' -import { Col } from 'web/components/layout/col' - -export function ContractBetsActivity(props: { - contract: Contract - bets: Bet[] - lps: LiquidityProvision[] -}) { - const { contract, bets, lps } = props - const [page, setPage] = useState(0) - const ITEMS_PER_PAGE = 50 - const start = page * ITEMS_PER_PAGE - const end = start + ITEMS_PER_PAGE - - const items = [ - ...bets.map((bet) => ({ - type: 'bet' as const, - id: bet.id + '-' + bet.isSold, - bet, - })), - ...lps.map((lp) => ({ - type: 'liquidity' as const, - id: lp.id, - lp, - })), - ] - - const pageItems = sortBy(items, (item) => - item.type === 'bet' - ? -item.bet.createdTime - : item.type === 'liquidity' - ? -item.lp.createdTime - : undefined - ).slice(start, end) - - return ( - <> - <Col className="mb-4 gap-4"> - {pageItems.map((item) => - item.type === 'bet' ? ( - <FeedBet key={item.id} contract={contract} bet={item.bet} /> - ) : ( - <FeedLiquidity key={item.id} liquidity={item.lp} /> - ) - )} - </Col> - <Pagination - page={page} - itemsPerPage={50} - totalItems={items.length} - setPage={setPage} - scrollToTop - nextTitle={'Older'} - prevTitle={'Newer'} - /> - </> - ) -} - -export function ContractCommentsActivity(props: { - contract: Contract - betsByCurrentUser: Bet[] - comments: ContractComment[] - tips: CommentTipMap - user: User | null | undefined -}) { - const { betsByCurrentUser, contract, comments, user, tips } = props - const commentsByUserId = groupBy(comments, (c) => c.userId) - const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') - const topLevelComments = sortBy( - commentsByParentId['_'] ?? [], - (c) => -c.createdTime - ) - - return ( - <> - <ContractCommentInput - className="mb-5" - contract={contract} - betsByCurrentUser={betsByCurrentUser} - commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} - /> - {topLevelComments.map((parent) => ( - <FeedCommentThread - key={parent.id} - user={user} - contract={contract} - parentComment={parent} - threadComments={sortBy( - commentsByParentId[parent.id] ?? [], - (c) => c.createdTime - )} - tips={tips} - betsByCurrentUser={betsByCurrentUser} - commentsByUserId={commentsByUserId} - /> - ))} - </> - ) -} - -export function FreeResponseContractCommentsActivity(props: { - contract: FreeResponseContract - betsByCurrentUser: Bet[] - comments: ContractComment[] - tips: CommentTipMap - user: User | null | undefined -}) { - const { betsByCurrentUser, contract, comments, user, tips } = props - - const sortedAnswers = sortBy( - contract.answers, - (answer) => -getOutcomeProbability(contract, answer.number.toString()) - ) - const commentsByUserId = groupBy(comments, (c) => c.userId) - 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} - user={user} - answer={answer} - answerComments={sortBy( - commentsByOutcome[answer.number.toString()] ?? [], - (c) => c.createdTime - )} - tips={tips} - betsByCurrentUser={betsByCurrentUser} - commentsByUserId={commentsByUserId} - /> - </div> - ))} - </> - ) -} diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 958b6d6d..e17ea578 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,8 +1,7 @@ import { Answer } from 'common/answer' -import { Bet } from 'common/bet' 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' @@ -11,109 +10,42 @@ import clsx from 'clsx' import { ContractCommentInput, FeedComment, - getMostRecentCommentableBet, + ReplyTo, } from 'web/components/feed/feed-comments' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { useRouter } from 'next/router' -import { Dictionary } from 'lodash' -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' export function FeedAnswerCommentGroup(props: { contract: FreeResponseContract - user: User | undefined | null answer: Answer answerComments: ContractComment[] tips: CommentTipMap - betsByCurrentUser: Bet[] - commentsByUserId: Dictionary<ContractComment[]> }) { - const { - answer, - contract, - answerComments, - tips, - betsByCurrentUser, - commentsByUserId, - user, - } = 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 commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] - const isFreeResponseContractPage = !!commentsByCurrentUser - const mostRecentCommentableBet = getMostRecentCommentableBet( - betsByCurrentUser, - commentsByCurrentUser, - user, - answer.number.toString() - ) - const [usersMostRecentBetTimeAtLoad, setUsersMostRecentBetTimeAtLoad] = - useState<number | undefined>( - !user ? undefined : mostRecentCommentableBet?.createdTime ?? 0 - ) + const highlighted = router.asPath.endsWith(`#${answerElementId}`) + const answerRef = useRef<HTMLDivElement>(null) useEffect(() => { - if (user && usersMostRecentBetTimeAtLoad === undefined) - setUsersMostRecentBetTimeAtLoad( - mostRecentCommentableBet?.createdTime ?? 0 - ) - }, [ - mostRecentCommentableBet?.createdTime, - user, - usersMostRecentBetTimeAtLoad, - ]) - - 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) + if (highlighted && answerRef.current != null) { + answerRef.current.scrollIntoView(true) } - ) - - useEffect(() => { - // Only show one comment input for a bet at a time - if ( - betsByCurrentUser.length > 1 && - // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty - betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] - ?.outcome !== answer.number.toString() - ) - setShowReply(false) - // Even if we pass memoized bets this still runs on every render, which we don't want - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [betsByCurrentUser.length, user, answer.number]) - - useEffect(() => { - if (router.asPath.endsWith(`#${answerElementId}`)) { - setHighlighted(true) - } - }, [answerElementId, router.asPath]) + }, [highlighted]) return ( - <Col - className={'relative flex-1 items-stretch gap-3'} - key={answer.id + 'comment'} - > + <Col className="relative flex-1 items-stretch gap-3"> <Row className={clsx( '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} /> @@ -133,28 +65,27 @@ export function FeedAnswerCommentGroup(props: { <span className="whitespace-pre-line text-lg"> <Linkify text={text} /> </span> - - {isFreeResponseContractPage && ( - <div className={'sm:hidden'}> - <button - className={'text-xs font-bold text-gray-500 hover:underline'} - onClick={() => scrollAndOpenReplyInput(undefined, answer)} - > - Reply - </button> - </div> - )} - </Col> - {isFreeResponseContractPage && ( - <div className={'justify-initial hidden sm:block'}> + <div className="sm:hidden"> <button - className={'text-xs font-bold text-gray-500 hover:underline'} - onClick={() => scrollAndOpenReplyInput(undefined, answer)} + className="text-xs font-bold text-gray-500 hover:underline" + onClick={() => + setReplyTo({ id: answer.id, username: answer.username }) + } > Reply </button> </div> - )} + </Col> + <div className="justify-initial hidden sm:block"> + <button + className="text-xs font-bold text-gray-500 hover:underline" + onClick={() => + setReplyTo({ id: answer.id, username: answer.username }) + } + > + Reply + </button> + </div> </Col> </Row> <Col className="gap-3 pl-1"> @@ -164,24 +95,24 @@ export function FeedAnswerCommentGroup(props: { indent={true} contract={contract} comment={comment} - tips={tips[comment.id]} - onReplyClick={scrollAndOpenReplyInput} + tips={tips[comment.id] ?? {}} + onReplyClick={() => + setReplyTo({ id: comment.id, username: comment.userUsername }) + } /> ))} </Col> - {showReply && ( - <div className={'relative ml-7'}> + {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" aria-hidden="true" /> <ContractCommentInput contract={contract} - betsByCurrentUser={betsByCurrentUser} - commentsByCurrentUser={commentsByCurrentUser} 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 0eca8915..1b62690b 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -1,9 +1,6 @@ -import { Bet } from 'common/bet' import { ContractComment } from 'common/comment' -import { User } from 'common/user' import { Contract } from 'common/contract' -import React, { useEffect, useState } from 'react' -import { Dictionary } from 'lodash' +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' @@ -23,31 +20,16 @@ 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: { - user: User | null | undefined contract: Contract threadComments: ContractComment[] tips: CommentTipMap parentComment: ContractComment - betsByCurrentUser: Bet[] - commentsByUserId: Dictionary<ContractComment[]> }) { - const { - user, - contract, - threadComments, - commentsByUserId, - betsByCurrentUser, - 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 { contract, threadComments, tips, parentComment } = props + const [replyTo, setReplyTo] = useState<ReplyTo>() return ( <Col className="relative w-full items-stretch gap-3 pb-4"> @@ -61,11 +43,13 @@ export function FeedCommentThread(props: { indent={commentIdx != 0} contract={contract} comment={comment} - tips={tips[comment.id]} - onReplyClick={scrollAndOpenReplyInput} + tips={tips[comment.id] ?? {}} + 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" @@ -73,14 +57,9 @@ export function FeedCommentThread(props: { /> <ContractCommentInput contract={contract} - betsByCurrentUser={(user && betsByCurrentUser) ?? []} - commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} parentCommentId={parentComment.id} - replyToUser={replyTo} - parentAnswerOutcome={parentComment.answerOutcome} - onSubmitComment={() => { - setShowReply(false) - }} + replyTo={replyTo} + onSubmitComment={() => setReplyTo(undefined)} /> </Col> )} @@ -91,9 +70,9 @@ export function FeedCommentThread(props: { export function FeedComment(props: { contract: Contract comment: ContractComment - tips: CommentTips + tips?: CommentTips indent?: boolean - onReplyClick?: (comment: ContractComment) => void + onReplyClick?: () => void }) { const { contract, comment, tips, indent, onReplyClick } = props const { @@ -115,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', @@ -187,11 +169,11 @@ export function FeedComment(props: { smallImage /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> - <Tipper comment={comment} tips={tips ?? {}} /> + {tips && <Tipper comment={comment} tips={tips} />} {onReplyClick && ( <button className="font-bold hover:underline" - onClick={() => onReplyClick(comment)} + onClick={onReplyClick} > Reply </button> @@ -202,34 +184,6 @@ export function FeedComment(props: { ) } -export function getMostRecentCommentableBet( - betsByCurrentUser: Bet[], - commentsByCurrentUser: ContractComment[], - user?: User | null, - answerOutcome?: string -) { - let sortedBetsByCurrentUser = betsByCurrentUser.sort( - (a, b) => b.createdTime - a.createdTime - ) - if (answerOutcome) { - sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1) - } - return sortedBetsByCurrentUser - .filter((bet) => { - if ( - canCommentOnBet(bet, user) && - !commentsByCurrentUser.some( - (comment) => comment.createdTime > bet.createdTime - ) - ) { - if (!answerOutcome) return true - return answerOutcome === bet.outcome - } - return false - }) - .pop() -} - function CommentStatus(props: { contract: Contract outcome: string @@ -247,16 +201,14 @@ function CommentStatus(props: { export function ContractCommentInput(props: { contract: Contract - betsByCurrentUser: Bet[] - commentsByCurrentUser: ContractComment[] className?: string parentAnswerOutcome?: string | undefined - replyToUser?: { id: string; username: string } + replyTo?: ReplyTo parentCommentId?: string onSubmitComment?: () => void }) { const user = useUser() - async function onSubmitComment(editor: Editor, betId: string | undefined) { + async function onSubmitComment(editor: Editor) { if (!user) { track('sign in to comment') return await firebaseLogin() @@ -265,37 +217,19 @@ export function ContractCommentInput(props: { props.contract.id, editor.getJSON(), user, - betId, props.parentAnswerOutcome, props.parentCommentId ) props.onSubmitComment?.() } - const mostRecentCommentableBet = getMostRecentCommentableBet( - props.betsByCurrentUser, - props.commentsByCurrentUser, - user, - props.parentAnswerOutcome - ) - - const { id } = mostRecentCommentableBet || { id: undefined } - return ( <CommentInput - replyToUser={props.replyToUser} + replyTo={props.replyTo} parentAnswerOutcome={props.parentAnswerOutcome} parentCommentId={props.parentCommentId} onSubmitComment={onSubmitComment} className={props.className} - presetId={id} /> ) } - -function canCommentOnBet(bet: Bet, user?: User | null) { - const { userId, createdTime, isRedemption } = bet - const isSelf = user?.id === userId - // You can comment if your bet was posted in the last hour - return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 -} 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..135f43a8 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -115,6 +115,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/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..980a3cfc 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -31,7 +31,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) => ( 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/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 58f57a8a..7e216be5 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -14,6 +14,8 @@ import { Col } from './layout/col' import { track } from 'web/lib/service/analytics' import { InfoTooltip } from './info-tooltip' import { BETTORS, PRESENT_BET } from 'common/user' +import { buildArray } from 'common/util/array' +import { useAdmin } from 'web/hooks/use-admin' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -28,31 +30,32 @@ export function LiquidityPanel(props: { contract: CPMMContract }) { setShowWithdrawal(true) }, [showWithdrawal, lpShares]) + const isCreator = user?.id === contract.creatorId + const isAdmin = useAdmin() + + if (!showWithdrawal && !isAdmin && !isCreator) return <></> + return ( <Tabs - tabs={[ - { - title: 'Subsidize', + tabs={buildArray( + (isCreator || isAdmin) && { + title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', content: <AddLiquidityPanel contract={contract} />, }, - ...(showWithdrawal - ? [ - { - title: 'Withdraw', - content: ( - <WithdrawLiquidityPanel - contract={contract} - lpShares={lpShares as { YES: number; NO: number }} - /> - ), - }, - ] - : []), + showWithdrawal && { + title: 'Withdraw', + content: ( + <WithdrawLiquidityPanel + contract={contract} + lpShares={lpShares as { YES: number; NO: number }} + /> + ), + }, { title: 'Pool', content: <ViewLiquidityPanel contract={contract} />, - }, - ]} + } + )} /> ) } 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 654357c5..eb51f2de 100644 --- a/web/components/onboarding/welcome.tsx +++ b/web/components/onboarding/welcome.tsx @@ -1,6 +1,9 @@ -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' import clsx from 'clsx' -import { useState } from 'react' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' + +import { User } from 'common/user' import { useUser } from 'web/hooks/use-user' import { updateUser } from 'web/lib/firebase/users' import { Col } from '../layout/col' @@ -27,16 +30,12 @@ export default function Welcome() { } } - async function setUserHasSeenWelcome() { - if (user) { - await updateUser(user.id, { ['shouldShowWelcome']: false }) - } + const setUserHasSeenWelcome = async () => { + if (user) await updateUser(user.id, { ['shouldShowWelcome']: false }) } const [groupSelectorOpen, setGroupSelectorOpen] = useState(false) - if (!user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <></> - const toggleOpen = (isOpen: boolean) => { setUserHasSeenWelcome() setOpen(isOpen) @@ -45,6 +44,12 @@ export default function Welcome() { setGroupSelectorOpen(true) } } + + const isTwitch = useIsTwitch(user) + + if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen)) + return <></> + return ( <> <GroupSelectorDialog @@ -89,6 +94,19 @@ export default function Welcome() { ) } +const useIsTwitch = (user: User | null | undefined) => { + const router = useRouter() + const isTwitch = router.pathname === '/twitch' + + useEffect(() => { + if (isTwitch && user?.shouldShowWelcome) { + updateUser(user.id, { ['shouldShowWelcome']: false }) + } + }, [isTwitch, user]) + + return isTwitch +} + function PageIndicator(props: { page: number; totalpages: number }) { const { page, totalpages } = props return ( 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/referrals-button.tsx b/web/components/referrals-button.tsx index 4b4f7095..b164e10c 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -64,6 +64,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..f9845fbe 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -254,6 +254,7 @@ export function UserPage(props: { user: User }) { </Row> )} <QueryUncontrolledTabs + className="mb-4" currentPageForAnalytics={'profile'} labelClassName={'pb-2 pt-1 '} tabs={[ @@ -283,7 +284,7 @@ export function UserPage(props: { user: User }) { title: 'Stats', content: ( <Col className="mb-8"> - <Row className={'mb-8 flex-wrap items-center gap-6'}> + <Row className="mb-8 flex-wrap items-center gap-x-6 gap-y-2"> <FollowingButton user={user} /> <FollowersButton user={user} /> <ReferralsButton user={user} /> diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index f2403a15..053e2386 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 { QueryClient, useQuery, useQueryClient } from 'react-query' import { MINUTE_MS } 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>() 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-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-prob-changes.tsx b/web/hooks/use-prob-changes.tsx index f3a6eee9..f2f3ce13 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 + ) + + return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse() } 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/comments.ts b/web/lib/firebase/comments.ts index e00d7397..db4e8ede 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -35,17 +35,13 @@ export async function createCommentOnContract( contractId: string, content: JSONContent, user: User, - betId?: string, answerOutcome?: string, replyToCommentId?: string ) { - const ref = betId - ? doc(getCommentsCollection(contractId), betId) - : doc(getCommentsCollection(contractId)) + const ref = doc(getCommentsCollection(contractId)) const onContract = { commentType: 'contract', contractId, - betId, answerOutcome, } as OnContract return await createComment( 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/package.json b/web/package.json index ba25a6e1..6ee29183 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index a0b2ed50..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' @@ -31,10 +30,7 @@ import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' -import { useTipTxns } from 'web/hooks/use-tip-txns' 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 { @@ -45,31 +41,24 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' import { usePrefetch } from 'web/hooks/use-prefetch' import { useAdmin } from 'web/hooks/use-admin' +import { BetSignUpPrompt } from 'web/components/sign-up-prompt' +import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' +import BetButton from 'web/components/bet-button' + import dayjs from 'dayjs' 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 } } @@ -80,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} /> @@ -106,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 @@ -116,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' @@ -170,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', @@ -192,8 +167,6 @@ export function ContractPageContent( [bets] ) - const tips = useTipTxns({ contractId: contract.id }) - const [showConfetti, setShowConfetti] = useState(false) useEffect(() => { @@ -205,18 +178,6 @@ export function ContractPageContent( setShowConfetti(shouldSeeConfetti) }, [contract, user]) - const [recommendedContracts, setRecommendedContracts] = useState<Contract[]>( - [] - ) - useEffect(() => { - if (contract && user) { - getRecommendedContracts(contract, user.id, 6).then( - setRecommendedContracts - ) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contract.id, user?.id]) - const { isResolved, question, outcomeType } = contract const allowTrade = tradingAllowed(contract) @@ -228,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} /> )} @@ -239,7 +199,7 @@ export function ContractPageContent( <SEO title={question} description={ogCardProps.description} - url={`/${props.username}/${props.slug}`} + url={`/${contract.creatorUsername}/${contract.slug}`} ogCardProps={ogCardProps} /> )} @@ -282,35 +242,55 @@ 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} - tips={tips} - /> + <ContractTopTrades contract={contract} bets={bets} /> </div> <Spacer h={12} /> </> )} - <ContractTabs - contract={contract} - user={user} - bets={bets} - tips={tips} - comments={comments} - /> + <ContractTabs contract={contract} bets={bets} /> + {!user ? ( + <Col className="mt-4 max-w-sm items-center xl:hidden"> + <BetSignUpPrompt /> + <PlayMoneyDisclaimer /> + </Col> + ) : ( + outcomeType === 'BINARY' && + allowTrade && ( + <BetButton + contract={contract as CPMMBinaryContract} + className="mb-2 !mt-0 xl:hidden" + /> + ) + )} </Col> - - {recommendedContracts.length > 0 && ( - <Col className="mt-2 gap-2 px-2 sm:px-0"> - <Title className="text-gray-700" text="Recommended" /> - <ContractsGrid - contracts={recommendedContracts} - trackingPostfix=" recommended" - /> - </Col> - )} + <RecommendedContractsWidget contract={contract} /> </Page> ) } + +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 + } + 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 a925a425..0a17e9e2 100644 --- a/web/pages/daily-movers.tsx +++ b/web/pages/daily-movers.tsx @@ -2,13 +2,19 @@ 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') return ( <Page> 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..0dfe40a0 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,6 +116,7 @@ const groupSubpages = [ 'markets', 'leaderboards', 'about', + 'posts', ] as const export default function GroupPage(props: { @@ -116,8 +126,9 @@ export default function GroupPage(props: { 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,8 +138,9 @@ 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[] } @@ -137,13 +149,15 @@ export default function GroupPage(props: { 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, @@ -157,7 +171,7 @@ export default function GroupPage(props: { 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 +190,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 +220,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 +244,46 @@ 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'}> + <Tabs className={'mb-2'} tabs={tabs} /> </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 +300,6 @@ export function TopGroupNavBar(props: { </h1> </div> </div> - <GroupNavBar currentPage={props.currentPage} onClick={props.onClick} /> </header> ) } @@ -330,11 +312,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 +435,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 +445,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..49d99d18 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -99,6 +99,7 @@ export default function Groups(props: { </div> <Tabs + className="mb-4" currentPageForAnalytics={'groups'} tabs={[ ...(user 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..de4e1c2f 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,8 @@ 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' export default function Home() { const user = useUser() @@ -54,20 +56,19 @@ export default function Home() { 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 +82,13 @@ export default function Home() { <DailyStats user={user} /> </Row> - {sections.map((section) => renderSection(section, user, groups))} + <> + {sections.map((section) => + renderSection(section, user, groups, groupContracts) + )} - <TrendingGroupsSection user={user} /> + <TrendingGroupsSection user={user} /> + </> </Col> <button type="button" @@ -101,9 +106,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[]) => { @@ -134,18 +139,19 @@ export const getHomeItems = (groups: Group[], sections: string[]) => { function renderSection( section: { id: string; label: string }, user: User | null | undefined, - groups: Group[] + groups: Group[] | undefined, + groupContracts: Dictionary<CPMMBinaryContract[]> | undefined ) { const { id, label } = section if (id === 'daily-movers') { 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 +162,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 } @@ -207,7 +228,6 @@ function SearchSection(props: { defaultPill={pill} noControls maxResults={6} - headerClassName="sticky" persistPrefix={`home-${sort}`} /> </Col> @@ -217,10 +237,9 @@ function SearchSection(props: { function GroupSection(props: { group: Group user: User | null | undefined | undefined + contracts: CPMMBinaryContract[] }) { - const { group, user } = props - - const contracts = useContractsQuery('score', 4, { groupSlug: group.slug }) + const { group, user, contracts } = props return ( <Col> @@ -245,22 +264,22 @@ function GroupSection(props: { <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 +351,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/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..8378b185 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: diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 46856eaf..22d3152d 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -1,8 +1,7 @@ import { LinkIcon } from '@heroicons/react/solid' import clsx from 'clsx' import { PrivateUser, User } from 'common/user' -import Link from 'next/link' -import { MouseEventHandler, ReactNode, useState } from 'react' +import { MouseEventHandler, ReactNode, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { Button } from 'web/components/button' @@ -17,11 +16,7 @@ import { Title } from 'web/components/title' import { useSaveReferral } from 'web/hooks/use-save-referral' import { useTracking } from 'web/hooks/use-tracking' import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { - firebaseLogin, - getUserAndPrivateUser, - updatePrivateUser, -} from 'web/lib/firebase/users' +import { firebaseLogin, updatePrivateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' import { getDockURLForUser, @@ -40,23 +35,32 @@ function ButtonGetStarted(props: { const { user, privateUser, buttonClass, spinnerClass } = props const [isLoading, setLoading] = useState(false) + const needsRelink = privateUser?.twitchInfo?.twitchName && privateUser?.twitchInfo?.needsRelinking + const [waitingForUser, setWaitingForUser] = useState(false) + useEffect(() => { + if (waitingForUser && user && privateUser) { + setWaitingForUser(false) + + if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again + + setLoading(true) + + linkTwitchAccountRedirect(user, privateUser).then(() => { + setLoading(false) + }) + } + }, [user, privateUser, waitingForUser]) + const callback = user && privateUser ? () => linkTwitchAccountRedirect(user, privateUser) : async () => { - const result = await firebaseLogin() - - const userId = result.user.uid - const { user, privateUser } = await getUserAndPrivateUser(userId) - if (!user || !privateUser) return - - if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again - - await linkTwitchAccountRedirect(user, privateUser) + await firebaseLogin() + setWaitingForUser(true) } const getStarted = async () => { @@ -110,20 +114,9 @@ function TwitchPlaysManifoldMarkets(props: { className={'!-my-0 md:block'} /> </Row> - <Col className="gap-4"> - <div> - Similar to Twitch channel point predictions, Manifold Markets allows - you to create and feature on stream any question you like with users - predicting to earn play money. - </div> - <div> - The key difference is that Manifold's questions function more like a - stock market and viewers can buy and sell shares over the course of - the event and not just at the start. The market will eventually - resolve to yes or no at which point the winning shareholders will - receive their profit. - </div> - Start playing now by logging in with Google and typing commands in chat! + <Col className="mb-4 gap-4"> + Start betting on Twitch now by linking your account and typing commands + in chat! {twitchUser && !twitchInfo.needsRelinking ? ( <Button size="xl" @@ -135,13 +128,25 @@ function TwitchPlaysManifoldMarkets(props: { ) : ( <ButtonGetStarted user={user} privateUser={privateUser} /> )} + </Col> + <Col className="gap-4"> + <Subtitle text="How it works" /> <div> - Instead of Twitch channel points we use our play money, Mana (M$). All - viewers start with M$1000 and more can be earned for free and then{' '} - <Link href="/charity"> - <a className="underline">donated to a charity</a> - </Link>{' '} - of their choice at no cost! + Similar to Twitch channel point predictions, Manifold Markets allows + you to create a play-money betting market on any question you like and + feature it in your stream. + </div> + <div> + The key difference is that Manifold's questions function more like a + stock market and viewers can buy and sell shares over the course of + the event and not just at the start. The market will eventually + resolve to yes or no at which point the winning shareholders will + receive their profit. + </div> + <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. </div> </Col> </div> @@ -170,21 +175,26 @@ function TwitchChatCommands() { <Title text="Twitch Chat Commands" className="md:block" /> <Col className="gap-4"> <Subtitle text="For Chat" /> - <Command command="bet yes#" desc="Bets a # of Mana on yes." /> - <Command command="bet no#" desc="Bets a # of Mana on no." /> + <Command + command="bet yes #" + desc="Bets an amount of M$ on yes, for example !bet yes 20" + /> + <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 + a loss, although at least you keep some mana. For maximum profit (but also risk) it is better to not sell and wait for a favourable resolution." /> - <Command command="balance" desc="Shows how much Mana you own." /> + <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." /> + <div className="mb-4" /> + <Subtitle text="For Mods/Streamer" /> <Command command="create <question>" @@ -194,7 +204,7 @@ function TwitchChatCommands() { <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." + desc="Resolves the market as 'N/A' and refunds everyone their mana." /> </Col> </div> 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 7bea3ec2..57bc2dc5 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: { 'greyscale-1': '#FBFBFF', 'greyscale-2': '#E7E7F4', diff --git a/yarn.lock b/yarn.lock index 89d43cba..81cf80fa 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,70 +2488,70 @@ 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" @@ -2933,10 +2933,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" @@ -4545,6 +4545,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" @@ -8637,31 +8642,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" @@ -11267,10 +11272,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"