Merge branch 'main' into austin/dc-hackathon

This commit is contained in:
Austin Chen 2022-10-05 21:42:58 -04:00
commit 86b489bd26
99 changed files with 1849 additions and 1099 deletions

View File

@ -210,7 +210,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
}
}
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
@ -221,8 +220,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return {
invested,
loan,
payout,
netPayout,
profit,
profitPercent,
totalShares,
@ -233,8 +232,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() {
return {
invested: 0,
loan: 0,
payout: 0,
netPayout: 0,
profit: 0,
profitPercent: 0,
totalShares: {} as { [outcome: string]: number },

View File

@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`,
If you would like to support our work, you can do so by getting involved or by donating.`,
},
{
{
name: 'CaRLA',
website: 'https://carlaef.org/',
photo: 'https://i.imgur.com/IsNVTOY.png',
@ -589,6 +589,14 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
},
{
name: 'Mriya',
website: 'https://mriya-ua.org/',
photo:
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`)
return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
subtitle: string
content: JSONContent
creatorId: string // User id
createdTime: number
@ -17,3 +18,4 @@ export type DateDoc = Post & {
}
export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

24
common/util/color.ts Normal file
View File

@ -0,0 +1,24 @@
export const interpolateColor = (color1: string, color2: string, p: number) => {
const rgb1 = parseInt(color1.replace('#', ''), 16)
const rgb2 = parseInt(color2.replace('#', ''), 16)
const [r1, g1, b1] = toArray(rgb1)
const [r2, g2, b2] = toArray(rgb2)
const q = 1 - p
const rr = Math.round(r1 * q + r2 * p)
const rg = Math.round(g1 * q + g2 * p)
const rb = Math.round(b1 * q + b2 * p)
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
return hex
}
function toArray(rgb: number) {
const r = rgb >> 16
const g = (rgb >> 8) % 256
const b = rgb % 256
return [r, g, b]
}

View File

@ -86,6 +86,15 @@ export function richTextToString(text?: JSONContent) {
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
} else if (current.type === 'image') {
current.text = '[Image]'
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
} else if (current.type === 'iframe') {
const src = current.attrs?.['src'] ? current.attrs['src'] : ''
current.text = '[Iframe]' + (src ? ` url:${src}` : '')
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
}
})
return generateText(newText, exhibitExts)

View File

@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import {
Post,
MAX_POST_TITLE_LENGTH,
MAX_POST_SUBTITLE_LENGTH,
} from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema,
groupId: z.string().optional(),
@ -48,10 +53,8 @@ const postSchema = z.object({
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content, groupId, question, ...otherProps } = validate(
postSchema,
req.body
)
const { title, subtitle, content, groupId, question, ...otherProps } =
validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
@ -89,6 +92,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorId: creator.id,
slug,
title,
subtitle,
createdTime: Date.now(),
content: content,
contractSlug,

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api'
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails'
// Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here
await sendPortfolioUpdateEmailsToAllUsers()
await sendTrendingMarketsEmailsToAllUsers()
return { success: true }
}

View File

@ -34,17 +34,20 @@ export const unsubscribe: EndpointDefinition = {
const previousDestinations =
user.notificationPreferences[notificationSubscriptionType]
let newDestinations = previousDestinations
if (wantsToOptOutAll) newDestinations.push('email')
else
newDestinations = previousDestinations.filter(
(destination) => destination !== 'email'
)
console.log(previousDestinations)
const { email } = user
const update: Partial<PrivateUser> = {
notificationPreferences: {
...user.notificationPreferences,
[notificationSubscriptionType]: wantsToOptOutAll
? previousDestinations.push('email')
: previousDestinations.filter(
(destination) => destination !== 'email'
),
[notificationSubscriptionType]: newDestinations,
},
}
@ -60,7 +63,7 @@ export const unsubscribe: EndpointDefinition = {
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Manifold Markets 7th Day Anniversary Gift!</title>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
@ -213,7 +216,7 @@ export const unsubscribe: EndpointDefinition = {
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Manifold Markets 7th Day Anniversary Gift!</title>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->

View File

@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1')
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19-20 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers()
@ -40,20 +43,30 @@ export async function getTrendingContracts() {
)
}
async function sendTrendingMarketsEmailsToAllUsers() {
export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6
const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
: filterDefined([
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers
.filter((user) => {
return (
// Get all users that haven't unsubscribed from weekly emails
.filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent
)
})
.slice(150) // Send the emails out in batches
)
.slice(0, 90) // Send the emails out in batches
// For testing different users on prod: (only send ian an email though)
// const privateUsersToSendEmailsTo = filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
// // isProd()
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
log(
'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length,
@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
)
.slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
.slice(0, 50)
// TODO: convert to Promise.all
for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
continue
}
const contractsAvailableToSend = trendingContracts.filter((contract) => {
return !contract.uniqueBettorIds?.includes(privateUser.id)
})
if (contractsAvailableToSend.length < numContractsToSend) {
log('not enough new, unbet-on contracts to send to user', privateUser.id)
await firestore.collection('private-users').doc(privateUser.id).update({
const uniqueTrendingContracts = removeSimilarQuestions(
trendingContracts,
trendingContracts,
true
).slice(0, 20)
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
return
}
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy(
[
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
// // Most people will belong to groups but may not follow other users,
// so choose more from the other subsets if the followed markets is sparse
...chooseRandomSubset(
unBetOnGroupMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
],
(contract) => contract.id
)
// // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend.length
log(
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
)
marketsAvailableToSend.push(
...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter(
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
)
.slice(0, trendingMarketsToSend)
)
}
if (marketsAvailableToSend.length < numContractsToSend) {
log(
'not enough new, unbet-on contracts to send to user',
privateUser.id
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyTrendingEmailSent: true,
})
return
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
marketsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) return
log(
'sending contracts:',
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
)
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
continue
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
contractsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
}
)
}
const MINIMUM_POPULARITY_SCORE = 10
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows')
)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('creatorId', '==', follow.userId)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
// log(
// 'top 10 sorted markets by followed users',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore
.collectionGroup('groupMembers')
.where('userId', '==', userId)
.get()
const groupIds = filterDefined(
snap.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
)
if (groups.length === 0) return []
const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('groupSlugs', 'array-contains', group.slug)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromGroups.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = removeSimilarQuestions(
uniqueSortedMarkets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted group markets',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
// Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>(
firestore
.collection('contracts')
.where('uniqueBettorIds', 'array-contains', userId)
)
if (contractsUserHasBetOn.length === 0) return []
// count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy(
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId
)
// sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts)
.sort((a, b) => b[1] - a[1])
.filter((bettorId) => bettorId[1] > 2)
.map((entry) => entry[0])
.filter((bettorId) => bettorId !== userId)
// get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10)
if (similarBettorIds.length === 0) return []
// get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = uniqBy(
(
await getValues<Contract>(
firestore
.collection('contracts')
.where(
'uniqueBettorIds',
'array-contains-any',
similarBettorIds.slice(0, 10)
)
.orderBy('popularityScore', 'desc')
.limit(200)
)
).filter(
(contract) =>
!contract.uniqueBettorIds?.includes(userId) &&
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
),
(contract) => contract.id
)
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
.map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId)
).length
return [contract, appearances] as [Contract, number]
})
.sort((a, b) => b[1] - a[1])
.map((entry) => entry[0])
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
sortedContractsInSimilarBettorsBets,
sortedContractsInSimilarBettorsBets,
true
)
const topMostSimilarContracts = removeSimilarQuestions(
uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted contracts other similar bettors have bet on',
// topMostSimilarContracts.map((c) => c.question)
// )
return topMostSimilarContracts
}
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter: Contract[],
byContracts: Contract[],
allowExactSameContracts: boolean
) => {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove: Contract[] = []
byContracts.length > 0 &&
byContracts.forEach((contract) => {
const contractQuestion = stripNonAlphaChars(
contract.question.toLowerCase()
)
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
(w) => !IGNORE_WORDS.includes(w)
)
contractsToRemove = contractsToRemove.concat(
contractsToFilter.filter(
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
(c2) => {
const significantOverlap =
// TODO: we should probably use a library for comparing strings/sentiments
uniq(
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
).filter((word) => contractQuestionWords.includes(word)).length >
2
const lessPopular =
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
return (
(significantOverlap && lessPopular) ||
(allowExactSameContracts ? false : c2.id === contract.id)
)
}
)
)
})
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter.filter(
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
)
return returnContracts
}
const fiveMinutes = 5 * 60 * 1000
@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng)
return contracts.slice(0, count)
}
function stripNonAlphaChars(str: string) {
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
}
const IGNORE_WORDS = [
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'will',
'be',
'is',
'are',
'for',
'by',
'at',
'from',
'what',
'when',
'which',
'that',
'it',
'as',
'if',
'then',
'than',
'but',
'have',
'has',
'had',
]

View File

@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
numericValue,
resolution,
} = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8">
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
</head>
<style>
${getTemplateCss(theme, fontSize)}
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div>
<div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion}
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
${question}
</div>
<div class="flex flex-col">
${
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata -->
<div class="absolute bottom-16">
<div class="text-gray-500 text-3xl max-w-[80vw]">
<div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2">
${metadata}
</div>
</div>

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1",
"ts-node": "10.9.1",
"typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
}
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return (
<Head>
<title>{title} | Manifold Markets</title>
<title>{`${title} | Manifold Markets`}</title>
<meta
property="og:title"

View File

@ -192,6 +192,7 @@ export function AnswerBetPanel(props: {
isSubmitting={isSubmitting}
disabled={!!betDisabled}
color={'indigo'}
actionLabel="Buy"
/>
) : (
<BetSignUpPrompt />

View File

@ -20,11 +20,11 @@ import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
@ -38,6 +38,7 @@ 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(
@ -104,6 +105,10 @@ export function AnswersPanel(props: {
? 'checkbox'
: undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
)
return (
<Col className="gap-3">
{(resolveOption || resolution) &&
@ -128,7 +133,12 @@ export function AnswersPanel(props: {
)}
>
{answerItems.map((item) => (
<OpenAnswer key={item.id} answer={item} contract={contract} />
<OpenAnswer
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
/>
))}
{hasZeroBetAnswers && !showAllAnswers && (
<Button
@ -174,15 +184,18 @@ export function AnswersPanel(props: {
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
colorIndex: number | undefined
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
const { answer, contract, colorIndex } = props
const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
return (
<Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Col className="my-1 px-2">
<Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel
answer={answer}
@ -193,40 +206,44 @@ function OpenAnswer(props: {
/>
</Modal>
<div
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
<Row className="my-4 gap-3">
<Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<Linkify className="whitespace-pre-line text-lg" text={text} />
<Row className="align-items items-center justify-end gap-4">
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
<Col
className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
>
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row>
<Avatar
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
username={username}
avatarUrl={avatarUrl}
/>
<Linkify
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
</Row>
<Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div>
{tradingAllowed(contract) && (
<Button
size="2xs"
color="gray-outline"
onClick={() => setOpen(true)}
/>
</Row>
</Col>
</Col>
</Row>
className="my-auto"
>
BUY
</Button>
)}
</Row>
</Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col>
</Col>
)
}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'

View File

@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
if (authUser != null) {
if (authUser) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
} else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])

View File

@ -25,7 +25,7 @@ import {
NoLabel,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
@ -395,6 +395,7 @@ export function BuyPanel(props: {
disabled={!!betDisabled || outcome === undefined}
size="xl"
color={outcome === 'NO' ? 'red' : 'green'}
actionLabel="Wager"
/>
)}
<button
@ -831,13 +832,21 @@ export function SellPanel(props: {
const unfilledBets = useUnfilledBets(contract.id) ?? []
const betDisabled = isSubmitting || !amount || error
const betDisabled = isSubmitting || !amount || error !== undefined
// Sell all shares if remaining shares would be < 1
const isSellingAllShares = amount === Math.floor(shares)
const sellQuantity = isSellingAllShares ? shares : amount
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const soldShares = Math.min(sellQuantity ?? 0, shares)
const saleFrac = soldShares / shares
const loanPaid = saleFrac * loanAmount
const { invested } = getContractBetMetrics(contract, userBets)
const costBasis = invested * saleFrac
async function submitSell() {
if (!user || !amount) return
@ -882,8 +891,23 @@ export function SellPanel(props: {
sharesOutcome,
unfilledBets
)
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference =
contract.outcomeType === 'PSEUDO_NUMERIC'
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const probChange = Math.abs(resultProb - initialProb)
const warning =
probChange >= 0.3
? `Are you sure you want to move the market by ${displayedDifference}?`
: undefined
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition(
openUserBets,
@ -923,14 +947,18 @@ export function SellPanel(props: {
label="Qty"
error={error}
disabled={isSubmitting}
inputClassName="w-full"
inputClassName="w-full ml-1"
/>
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds
Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Profit
<span className="text-neutral">{formatMoney(profit)}</span>
</Row>
<Row className="items-center justify-between">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -941,24 +969,33 @@ export function SellPanel(props: {
{format(resultProb)}
</div>
</Row>
{loanPaid !== 0 && (
<>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-neutral">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
</Col>
<Spacer h={8} />
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: sharesOutcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitSell}
>
{isSubmitting ? 'Submitting...' : 'Submit sell'}
</button>
<WarningConfirmationButton
marketType="binary"
amount={undefined}
warning={warning}
isSubmitting={isSubmitting}
onSubmit={betDisabled ? undefined : submitSell}
disabled={!!betDisabled}
size="xl"
color="blue"
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
/>
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
</>

View File

@ -160,26 +160,31 @@ export function BetsList(props: { user: User }) {
unsettled,
(c) => contractsMetrics[c.id].payout
)
const currentNetInvestment = sumBy(
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col>
<Row className="justify-between gap-4 sm:flex-row">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentNetInvestment)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-2">
<select
@ -206,7 +211,7 @@ export function BetsList(props: { user: User }) {
<option value="closeTime">Close date</option>
</select>
</Row>
</Row>
</Col>
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
@ -612,7 +617,7 @@ function SellButton(props: {
label: 'Sell',
disabled: isSubmitting,
}}
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
submitBtn={{ label: 'Sell', color: 'green' }}
onSubmit={async () => {
setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id })

View File

@ -10,16 +10,54 @@ export type ColorType =
| 'indigo'
| 'yellow'
| 'gray'
| 'gray-outline'
| 'gradient'
| 'gray-white'
| 'highlight-blue'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}
export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx(
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
)
}
export function Button(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
color?: ColorType
color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
@ -35,42 +73,10 @@ export function Button(props: {
loading,
} = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return (
<button
type={type}
className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses,
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
className={clsx(buttonClass(size, color), className)}
disabled={disabled || loading}
onClick={onClick}
>

16
web/components/card.tsx Normal file
View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
export function Card(props: JSX.IntrinsicElements['div']) {
const { children, className, ...rest } = props
return (
<div
className={clsx(
'cursor-pointer rounded-lg border-2 bg-white transition-shadow hover:shadow-md focus:shadow-md',
className
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,8 +1,7 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { SwitchVerticalIcon } from '@heroicons/react/outline'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
@ -16,7 +15,6 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
@ -26,6 +24,7 @@ import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
type challengeInfo = {
amount: number
@ -302,16 +301,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div>
<button
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<CopyLinkButton url={challengeSlug} />
<QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}>

View File

@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props
@ -15,43 +17,44 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount)
return (
<Link href={`/charity/${slug}`} passHref>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<div className="card-body">
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Link href={`/charity/${slug}`}>
<a className="flex-1">
<Card className="!rounded-2xl">
<Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />}
</Row>
<div className="px-8">
<figure className="relative h-32">
{photo ? (
<Image src={photo} alt="" layout="fill" objectFit="contain" />
) : (
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
)}
</figure>
</div>
<Col className="p-8">
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
raised
</Row>
{/* {match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)} */}
</Row>
</>
)}
</div>
</div>
</Row>
</>
)}
</Col>
</Card>
</a>
</Link>
)
}

View File

@ -31,9 +31,9 @@ const getBetPoints = (bets: Bet[]) => {
}
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}

View File

@ -19,62 +19,59 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
const CATEGORY_COLORS = [
'#00b8dd',
'#eecafe',
'#874c62',
'#6457ca',
'#f773ba',
'#9c6bbc',
'#a87744',
'#af8a04',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
export const CATEGORY_COLORS = [
'#7eb0d5',
'#fd7f6f',
'#b2e061',
'#bd7ebe',
'#ffb55a',
'#ffee65',
'#beb9db',
'#fdcce5',
'#8bd3c7',
'#bddfb7',
'#e2e3f3',
'#fafafa',
'#9fcdeb',
'#d3d3d3',
'#b1a296',
'#e1bdb6',
'#f2dbc0',
'#fae5d3',
'#c5e0ec',
'#e0f0ff',
'#ffddcd',
'#fbd5e2',
'#f2e7e5',
'#ffe7ba',
'#eed9c4',
'#ea9999',
'#f9cb9c',
'#ffe599',
'#b6d7a8',
'#a2c4c9',
'#9fc5e8',
'#b4a7d6',
'#d5a6bd',
'#e06666',
'#f6b26b',
'#ffd966',
'#93c47d',
'#76a5af',
'#6fa8dc',
'#8e7cc3',
'#c27ba0',
'#cc0000',
'#e69138',
'#f1c232',
'#6aa84f',
'#45818e',
'#3d85c6',
'#674ea7',
'#a64d79',
'#990000',
'#b45f06',
'#bf9000',
]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
@ -144,6 +141,15 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
)
}
export function useChartAnswers(
contract: FreeResponseContract | MultipleChoiceContract
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
}
export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
@ -153,10 +159,7 @@ export const ChoiceContractChart = (props: {
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo(
() => [
@ -180,9 +183,9 @@ export const ChoiceContractChart = (props: {
const ChoiceTooltip = useMemo(
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
const legendItems = sortBy(
data.y.map((p, i) => ({
color: CATEGORY_COLORS[i],

View File

@ -26,11 +26,11 @@ const getNumericChartData = (contract: NumericContract) => {
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
const { data, x, xScale } = props
const amount = xScale.invert(x)
return (
<>
<span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-semibold">{formatLargeNumber(amount)}</span>
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</>
)

View File

@ -45,9 +45,9 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>>
) => {
const { data, mouseX, xScale } = props
const { data, x, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}

View File

@ -7,6 +7,8 @@ import {
CurveFactory,
SeriesPoint,
curveLinear,
curveStepBefore,
curveStepAfter,
stack,
stackOrderReverse,
} from 'd3-shape'
@ -19,6 +21,8 @@ import {
AreaPath,
AreaWithTopStroke,
Point,
SliceMarker,
TooltipParams,
TooltipComponent,
computeColorStops,
formatPct,
@ -32,21 +36,42 @@ export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount'
type SliceExtent = { y0: number; y1: number }
const interpolateY = (
curve: CurveFactory,
x: number,
x0: number,
x1: number,
y0: number,
y1: number
) => {
if (curve === curveLinear) {
const p = (x - x0) / (x1 - x0)
return y0 * (1 - p) + y1 * p
} else if (curve === curveStepAfter) {
return y0
} else if (curve === curveStepBefore) {
return y1
}
}
const getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
}
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
const i = bisect.left(data, x)
const prev = data[i - 1] as P | undefined
const next = data[i] as P | undefined
return { prev, next, x: posX }
}
}
@ -64,6 +89,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
}) => {
const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale
@ -78,13 +104,19 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis }
}, [w, xScale, yScale])
const selector = betAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -103,8 +135,10 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
<AreaWithTopStroke
@ -134,6 +168,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
}) => {
const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
@ -168,16 +203,23 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data)
}, [data])
const selector = betAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
@ -193,8 +235,10 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{series.map((s, i) => (
@ -226,13 +270,15 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { data, w, h, color, margin, yScale, yKind, curve, Tooltip } = props
const { data, w, h, color, margin, yScale, yKind, Tooltip } = props
const curve = props.curve ?? curveLinear
const [mouse, setMouse] = useState<TooltipParams<P> & SliceExtent>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py0 = yScale(0)
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
@ -253,21 +299,53 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale])
const selector = betAtPointSelector(data, xScale)
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p)
return p
props.onMouseOver?.(p.prev)
const x0 = p.prev ? xScale(p.prev.x) : xScale.range()[0]
const x1 = p.next ? xScale(p.next.x) : xScale.range()[1]
const y0 = p.prev ? yScale(p.prev.y) : yScale.range()[0]
const y1 = p.next ? yScale(p.next.y) : yScale.range()[1]
const markerY = interpolateY(curve, mouseX, x0, x1, y0, y1)
if (p.prev && markerY) {
setMouse({
x: mouseX,
y: markerY,
y0: py0,
y1: markerY,
data: p.prev,
})
} else {
setMouse(undefined)
}
})
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
const newViewXScale = xScale
.copy()
.domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else {
setViewXScale(undefined)
yScale.domain([0, 1])
}
})
@ -285,8 +363,12 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={
mouse ? { x: mouse.x, y: mouse.y, data: mouse.data } : undefined
}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{stops && (
@ -306,6 +388,9 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
py1={py1}
curve={curve ?? curveLinear}
/>
{mouse && (
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
)}
</SVGChart>
)
}

View File

@ -1,12 +1,4 @@
import {
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush'
@ -17,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
@ -123,6 +116,28 @@ export const AreaWithTopStroke = <P,>(props: {
)
}
export const SliceMarker = (props: {
color: string
x: number
y0: number
y1: number
}) => {
const { color, x, y0, y1 } = props
return (
<g>
<line stroke="white" strokeWidth={1} x1={x} x2={x} y1={y0} y2={y1} />
<circle
stroke="white"
strokeWidth={1}
fill={color}
cx={x}
cy={y1}
r={5}
/>
</g>
)
}
export const SVGChart = <X, TT>(props: {
children: ReactNode
w: number
@ -130,8 +145,10 @@ export const SVGChart = <X, TT>(props: {
margin: Margin
xAxis: Axis<X>
yAxis: Axis<number>
ttParams: TooltipParams<TT> | undefined
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
onMouseOver?: (mouseX: number, mouseY: number) => void
onMouseLeave?: () => void
Tooltip?: TooltipComponent<X, TT>
}) => {
const {
@ -141,16 +158,18 @@ export const SVGChart = <X, TT>(props: {
margin,
xAxis,
yAxis,
onMouseOver,
ttParams,
onSelect,
onMouseOver,
onMouseLeave,
Tooltip,
} = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null)
const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false)
useEffect(() => {
@ -165,7 +184,7 @@ export const SVGChart = <X, TT>(props: {
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouse(undefined)
onMouseLeave?.()
if (overlayRef.current) {
select(overlayRef.current).call(brush.clear)
}
@ -181,44 +200,49 @@ export const SVGChart = <X, TT>(props: {
.select('.selection')
.attr('shape-rendering', 'null')
}
}, [innerW, innerH, onSelect])
}, [innerW, innerH, onSelect, onMouseLeave])
const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouse(undefined)
}
onMouseOver(x, y)
}
}
const onTouchMove = (ev: React.TouchEvent) => {
if (onMouseOver) {
const touch = ev.touches[0]
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
onMouseOver(x, y)
}
}
const onPointerLeave = () => {
setMouse(undefined)
onMouseLeave?.()
}
return (
<div className="relative overflow-hidden">
{mouse && Tooltip && (
{ttParams && Tooltip && (
<TooltipContainer
setElem={tooltipMeasure.setElem}
margin={margin}
pos={getTooltipPosition(
mouse.x,
mouse.y,
ttParams.x,
ttParams.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
tooltipMeasure.width ?? 140,
tooltipMeasure.height ?? 35,
isMobile ?? false
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
x={ttParams.x}
y={ttParams.y}
data={ttParams.data}
/>
</TooltipContainer>
)}
@ -230,18 +254,30 @@ export const SVGChart = <X, TT>(props: {
<XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g>
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
{!isMobile ? (
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g>
</svg>
</div>
@ -255,31 +291,34 @@ export const getTooltipPosition = (
mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
tooltipWidth: number,
tooltipHeight: number,
isMobile: boolean
) => {
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
export type TooltipParams<T> = { x: number; y: number; data: T }
export type TooltipProps<X, T> = TooltipParams<T> & {
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>

View File

@ -22,8 +22,8 @@ const getPoints = (startDate: number, dailyValues: number[]) => {
}
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
const { data, x, xScale } = props
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
@ -33,8 +33,8 @@ const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
}
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
const { data, x, xScale } = props
const d = xScale.invert(x)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>

View File

@ -138,16 +138,6 @@ export function CommentInputTextArea(props: {
<LoadingIndicator spinnerClassName="border-gray-500" />
)}
</TextEditor>
<Row>
{!user && (
<button
className="btn btn-outline btn-sm mt-2 normal-case"
onClick={submitComment}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -16,11 +16,11 @@ export function ConfirmationButton(props: {
}
cancelBtn?: {
label?: string
className?: string
color?: ColorType
}
submitBtn?: {
label?: string
className?: string
color?: ColorType
isSubmitting?: boolean
}
children: ReactNode
@ -53,14 +53,14 @@ export function ConfirmationButton(props: {
<Col className="gap-4 rounded-md bg-white px-8 py-6">
{children}
<Row className="gap-4">
<div
className={clsx('btn', cancelBtn?.className)}
<Button
color={cancelBtn?.color ?? 'gray-white'}
onClick={() => updateOpen(false)}
>
{cancelBtn?.label ?? 'Cancel'}
</div>
</Button>
<Button
className={clsx('btn', submitBtn?.className)}
color={submitBtn?.color ?? 'blue'}
onClick={
onSubmitWithSuccess
? () =>
@ -100,18 +100,11 @@ export function ResolveConfirmationButton(props: {
onResolve: () => void
isSubmitting: boolean
openModalButtonClass?: string
submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) {
const {
onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
const { onResolve, isSubmitting, openModalButtonClass, color, disabled } =
props
return (
<ConfirmationButton
openModalBtn={{
@ -126,7 +119,7 @@ export function ResolveConfirmationButton(props: {
}}
submitBtn={{
label: 'Resolve',
className: clsx('border-none', submitButtonClass),
color: color,
isSubmitting,
}}
onSubmit={onResolve}

View File

@ -446,7 +446,7 @@ function ContractSearchControls(props: {
className="input input-bordered w-full"
autoFocus={autoFocus}
/>
{!isMobile && (
{!isMobile && !query && (
<SearchFilters
filter={filter}
selectFilter={selectFilter}
@ -457,7 +457,7 @@ function ContractSearchControls(props: {
includeProbSorts={includeProbSorts}
/>
)}
{isMobile && (
{isMobile && !query && (
<>
<MobileSearchBar
children={

View File

@ -8,6 +8,7 @@ import {
BinaryContract,
Contract,
CPMMBinaryContract,
CPMMContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
@ -35,6 +36,7 @@ import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip'
import { SiteLink } from '../site-link'
import { ProbChange } from './prob-change-table'
import { Card } from '../card'
export function ContractCard(props: {
contract: Contract
@ -75,12 +77,7 @@ export function ContractCard(props: {
!hideQuickBet
return (
<Row
className={clsx(
'group relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
className
)}
>
<Card className={clsx('group relative flex gap-3', className)}>
<Col className="relative flex-1 gap-3 py-4 pb-12 pl-6">
<AvatarDetails
contract={contract}
@ -195,7 +192,7 @@ export function ContractCard(props: {
/>
</Link>
)}
</Row>
</Card>
)
}
@ -391,7 +388,7 @@ export function PseudoNumericResolutionOrExpectation(props: {
}
export function ContractCardProbChange(props: {
contract: CPMMBinaryContract
contract: CPMMContract
noLinkAvatar?: boolean
className?: string
}) {
@ -399,12 +396,7 @@ export function ContractCardProbChange(props: {
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
return (
<Col
className={clsx(
className,
'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
)}
>
<Card className={clsx(className, 'mb-4')}>
<AvatarDetails
contract={contract}
className={'px-6 pt-4'}
@ -419,6 +411,6 @@ export function ContractCardProbChange(props: {
</SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} />
</Row>
</Col>
</Card>
)
}

View File

@ -357,7 +357,7 @@ export function GroupDisplay(props: {
const groupSection = (
<a
className={clsx(
'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
'bg-greyscale-4 max-w-[200px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
!disabled && 'hover:bg-greyscale-3 cursor-pointer'
)}
>
@ -437,14 +437,14 @@ function EditableCloseDate(props: {
return (
<>
<Modal
size="sm"
size="md"
open={isEditingCloseTime}
setOpen={setIsEditingCloseTime}
position="top"
>
<Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit Close Date" />
<Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
<Subtitle text="Edit market close time" />
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
<input
type="date"
className="input input-bordered w-full shrink-0 sm:w-fit"
@ -461,22 +461,18 @@ function EditableCloseDate(props: {
min="00:00"
value={closeHoursMinutes}
/>
<Button size={'xs'} color={'indigo'} onClick={() => onSave()}>
Set
</Button>
</Row>
<Button
className="mt-4"
className="mt-8"
size={'xs'}
color={'indigo'}
onClick={() => onSave()}
>
Done
</Button>
<Button
className="mt-4"
size={'xs'}
color={'gray-white'}
color="red"
onClick={() => onSave(Date.now())}
>
Close Now
Close market now
</Button>
</Col>
</Modal>

View File

@ -17,7 +17,7 @@ import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button'
import { DuplicateContractButton } from '../duplicate-contract-button'
import { Row } from '../layout/row'
import { BETTORS, User } from 'common/user'
import { Button } from '../button'

View File

@ -0,0 +1,54 @@
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import Link from 'next/link'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { fromNow } from 'web/lib/util/time'
import { BinaryContractOutcomeLabel } from '../outcome-label'
import { getColor } from './quick-bet'
export function ContractMention(props: { contract: Contract }) {
const { contract } = props
const { outcomeType, resolution } = contract
const probTextColor = `text-${getColor(contract)}`
return (
<Link href={contractPath(contract)}>
<a
className="group inline whitespace-nowrap rounded-sm hover:bg-indigo-50 focus:bg-indigo-50"
title={tooltipLabel(contract)}
>
<span className="break-anywhere mr-0.5 whitespace-normal font-normal text-indigo-700">
{contract.question}
</span>
{outcomeType === 'BINARY' && (
<span
className={clsx(
probTextColor,
'rounded-full px-2 font-semibold ring-1 ring-inset ring-indigo-100 group-hover:ring-indigo-200'
)}
>
{resolution ? (
<BinaryContractOutcomeLabel
contract={contract}
resolution={resolution}
/>
) : (
getBinaryProbPercent(contract)
)}
</span>
)}
{/* TODO: numeric? */}
</a>
</Link>
)
}
function tooltipLabel(contract: Contract) {
const { resolutionTime, creatorName, volume, closeTime = 0 } = contract
const dateFormat = resolutionTime
? `Resolved ${fromNow(resolutionTime)}`
: `${closeTime < Date.now() ? 'Closed' : 'Closes'} ${fromNow(closeTime)}`
return `By ${creatorName}. ${formatMoney(volume)} bet. ${dateFormat}`
}

View File

@ -48,15 +48,8 @@ export function ContractReportResolution(props: { contract: Contract }) {
openModalBtn={{
label: '',
icon: <FlagIcon className="h-5 w-5" />,
className: clsx(flagClass, reporting && 'btn-disabled loading'),
}}
cancelBtn={{
label: 'Cancel',
className: 'border-none btn-sm btn-ghost self-center',
}}
submitBtn={{
label: 'Submit',
className: 'btn-secondary',
disabled: reporting,
className: clsx(flagClass),
}}
onSubmitWithSuccess={onSubmit}
disabled={userReported}

View File

@ -45,7 +45,7 @@ export function ContractsGrid(props: {
cardUIOptions || {}
const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback(
(visible) => {
(visible: boolean) => {
if (visible && loadMore) {
loadMore()
}

View File

@ -1,13 +1,10 @@
import { sortBy } from 'lodash'
import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { SiteLink } from '../site-link'
import { sortBy } from 'lodash'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractCardProbChange } from './contract-card'
export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined
@ -39,46 +36,21 @@ export function ProbChangeTable(props: {
if (rows === 0) return <div className="px-4 text-gray-500">None</div>
return (
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="flex-1 divide-y">
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1 gap-4">
{filteredPositiveChanges.map((contract) => (
<ProbChangeRow key={contract.id} contract={contract} />
<ContractCardProbChange key={contract.id} contract={contract} />
))}
</Col>
<Col className="flex-1 divide-y">
<Col className="flex-1 gap-4">
{filteredNegativeChanges.map((contract) => (
<ProbChangeRow key={contract.id} contract={contract} />
<ContractCardProbChange key={contract.id} contract={contract} />
))}
</Col>
</Col>
)
}
export function ProbChangeRow(props: {
contract: CPMMContract
className?: string
}) {
const { className } = props
const contract =
(useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
return (
<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)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
<ProbChange className="py-2 pr-4 text-xl" contract={contract} />
</Row>
)
}
export function ProbChange(props: {
contract: CPMMContract
className?: string

View File

@ -1,6 +1,3 @@
import { LinkIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
@ -9,9 +6,7 @@ import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track, withTracking } from 'web/lib/service/analytics'
import { withTracking } from 'web/lib/service/analytics'
import { ENV_CONFIG } from 'common/envs/constants'
import { User } from 'common/user'
import { SiteLink } from '../site-link'
@ -22,6 +17,8 @@ import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
import { QRCode } from '../qr-code'
import { CopyLinkButton } from '../copy-link-button'
import { Button } from '../button'
export function ShareModal(props: {
contract: Contract
@ -34,7 +31,6 @@ export function ShareModal(props: {
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false)
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
@ -61,40 +57,24 @@ export function ShareModal(props: {
width={150}
height={150}
/>
<Button
size="2xl"
color="indigo"
className={'mb-2 flex max-w-xs self-center'}
onClick={() => {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: linkIcon,
})
track('copy share link')
}}
>
{linkIcon} Copy link
</Button>
<CopyLinkButton url={shareUrl} tracking="copy share link" />
<Row className="z-0 flex-wrap justify-center gap-4 self-center">
<TweetButton
className="self-start"
tweetText={getTweetText(contract, shareUrl)}
/>
<TweetButton tweetText={getTweetText(contract, shareUrl)} />
<ShareEmbedButton contract={contract} />
{showChallenge && (
<button
className={
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500'
}
<Button
size="2xs"
color="override"
className="gap-1 border-2 border-indigo-500 text-indigo-500 hover:bg-indigo-500 hover:text-white"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge
<ChallengeIcon className="h-4 w-4" /> Challenge
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={(open) => {
@ -106,7 +86,7 @@ export function ShareModal(props: {
user={user}
contract={contract}
/>
</button>
</Button>
)}
</Row>
</Col>

View File

@ -21,10 +21,11 @@ export function CopyLinkButton(props: {
return (
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
className="block w-full rounded-none rounded-l-md border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
readOnly
type="text"
value={displayUrl ?? url}
onFocus={(e) => e.target.select()}
/>
<Menu
@ -37,7 +38,7 @@ export function CopyLinkButton(props: {
>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
'relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500',
buttonClassName
)}
>

View File

@ -13,6 +13,8 @@ import { Group } from 'common/group'
export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('')
const [subtitle, setSubtitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@ -22,12 +24,17 @@ export function CreatePost(props: { group?: Group }) {
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
const isValid =
editor &&
title.length > 0 &&
subtitle.length > 0 &&
editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
subtitle: subtitle,
content: editor.getJSON(),
groupId: group?.id,
}
@ -62,6 +69,20 @@ export function CreatePost(props: { group?: Group }) {
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Subtitle<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. How Elon Musk is getting everyone's attention"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={subtitle}
onChange={(e) => setSubtitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>

View File

@ -3,27 +3,22 @@ import clsx from 'clsx'
import { Contract } from 'common/contract'
import { getMappedValue } from 'common/pseudo-numeric'
import { trackCallback } from 'web/lib/service/analytics'
import { buttonClass } from './button'
export function DuplicateContractButton(props: {
contract: Contract
className?: string
}) {
const { contract, className } = props
export function DuplicateContractButton(props: { contract: Contract }) {
const { contract } = props
return (
<a
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
style={{
backgroundColor: 'white',
border: '2px solid #a78bfa',
// violet-400
color: '#a78bfa',
}}
className={clsx(
buttonClass('2xs', 'override'),
'gap-1 border-2 border-violet-400 text-violet-400 hover:bg-violet-400 hover:text-white'
)}
href={duplicateContractHref(contract)}
onClick={trackCallback('duplicate market')}
target="_blank"
>
<DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
<DuplicateIcon className="h-4 w-4" aria-hidden="true" />
<div>Duplicate</div>
</a>
)
@ -40,6 +35,7 @@ function duplicateContractHref(contract: Contract) {
closeTime,
description: descriptionString,
outcomeType: contract.outcomeType,
visibility: contract.visibility,
} as Record<string, any>
if (contract.outcomeType === 'PSEUDO_NUMERIC') {

View File

@ -319,12 +319,12 @@ const useUploadMutation = (editor: Editor | null) =>
{
onSuccess(urls) {
if (!editor) return
let trans = editor.view.state.tr
urls.forEach((src: any) => {
const node = editor.view.state.schema.nodes.image.create({ src })
trans = trans.insert(editor.view.state.selection.to, node)
let trans = editor.chain().focus()
urls.forEach((src) => {
trans = trans.createParagraphNear()
trans = trans.setImage({ src })
})
editor.view.dispatch(trans)
trans.run()
},
}
)

View File

@ -6,7 +6,7 @@ import {
} from '@tiptap/react'
import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract'
import { ContractCard } from '../contract/contract-card'
import { ContractMention } from '../contract/contract-mention'
const name = 'contract-mention-component'
@ -14,13 +14,8 @@ const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id)
return (
<NodeViewWrapper className={clsx(name, 'not-prose')}>
{contract && (
<ContractCard
contract={contract}
className="my-2 w-full border border-gray-100"
/>
)}
<NodeViewWrapper className={clsx(name, 'not-prose inline')}>
{contract && <ContractMention contract={contract} />}
</NodeViewWrapper>
)
}
@ -37,6 +32,5 @@ export const DisplayContractMention = Mention.extend({
addNodeView: () =>
ReactNodeViewRenderer(ContractMentionComponent, {
// On desktop, render cards below half-width so you can stack two
className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1',
}),
})

View File

@ -2,6 +2,7 @@ import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import React, { useEffect, useRef, useState } from 'react'
import { sum } from 'lodash'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
@ -14,6 +15,8 @@ import {
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link'
@ -27,11 +30,17 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer
const [replyTo, setReplyTo] = useState<ReplyTo>()
const user = useUser()
const router = useRouter()
const answerElementId = `answer-${answer.id}`
const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const answerRef = useRef<HTMLDivElement>(null)
const onSubmitComment = useEvent(() => setReplyTo(undefined))
const onReplyClick = useEvent((comment: ContractComment) => {
setReplyTo({ id: comment.id, username: comment.userUsername })
})
useEffect(() => {
if (highlighted && answerRef.current != null) {
answerRef.current.scrollIntoView(true)
@ -95,10 +104,10 @@ export function FeedAnswerCommentGroup(props: {
indent={true}
contract={contract}
comment={comment}
tips={tips[comment.id] ?? {}}
onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true}
onReplyClick={onReplyClick}
/>
))}
</Col>
@ -112,7 +121,7 @@ export function FeedAnswerCommentGroup(props: {
contract={contract}
parentAnswerOutcome={answer.number.toString()}
replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)}
onSubmitComment={onSubmitComment}
/>
</div>
)}

View File

@ -1,3 +1,4 @@
import React, { memo, useEffect } from 'react'
import dayjs from 'dayjs'
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
@ -8,7 +9,6 @@ import clsx from 'clsx'
import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { useEffect } from 'react'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
@ -16,7 +16,10 @@ import { Challenge } from 'common/challenge'
import { UserLink } from 'web/components/user-link'
import { BETTOR } from 'common/user'
export function FeedBet(props: { contract: Contract; bet: Bet }) {
export const FeedBet = memo(function FeedBet(props: {
contract: Contract
bet: Bet
}) {
const { contract, bet } = props
const { userAvatarUrl, userUsername, createdTime } = bet
const showUser = dayjs(createdTime).isAfter('2022-06-01')
@ -36,7 +39,7 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
/>
</Row>
)
}
})
export function BetStatusText(props: {
contract: Contract

View File

@ -1,11 +1,14 @@
import React, { memo, useEffect, useRef, useState } from 'react'
import { Editor } from '@tiptap/react'
import { useRouter } from 'next/router'
import { sum } from 'lodash'
import clsx from 'clsx'
import { ContractComment } from 'common/comment'
import { Contract } from 'common/contract'
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'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
@ -14,9 +17,9 @@ import { createCommentOnContract } from 'web/lib/firebase/comments'
import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useEvent } from 'web/hooks/use-event'
import { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button'
@ -32,6 +35,12 @@ export function FeedCommentThread(props: {
const { contract, threadComments, tips, parentComment } = props
const [replyTo, setReplyTo] = useState<ReplyTo>()
const user = useUser()
const onSubmitComment = useEvent(() => setReplyTo(undefined))
const onReplyClick = useEvent((comment: ContractComment) => {
setReplyTo({ id: comment.id, username: comment.userUsername })
})
return (
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
@ -44,10 +53,10 @@ export function FeedCommentThread(props: {
indent={commentIdx != 0}
contract={contract}
comment={comment}
tips={tips[comment.id] ?? {}}
onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true}
onReplyClick={onReplyClick}
/>
))}
{replyTo && (
@ -60,7 +69,7 @@ export function FeedCommentThread(props: {
contract={contract}
parentCommentId={parentComment.id}
replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)}
onSubmitComment={onSubmitComment}
/>
</Col>
)}
@ -68,14 +77,17 @@ export function FeedCommentThread(props: {
)
}
export function FeedComment(props: {
export const FeedComment = memo(function FeedComment(props: {
contract: Contract
comment: ContractComment
tips?: CommentTips
showTip?: boolean
myTip?: number
totalTip?: number
indent?: boolean
onReplyClick?: () => void
onReplyClick?: (comment: ContractComment) => void
}) {
const { contract, comment, tips, indent, onReplyClick } = props
const { contract, comment, myTip, totalTip, showTip, indent, onReplyClick } =
props
const {
text,
content,
@ -180,12 +192,18 @@ export function FeedComment(props: {
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={onReplyClick}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
{tips && <Tipper comment={comment} tips={tips} />}
{showTip && (
<Tipper
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
@ -193,7 +211,7 @@ export function FeedComment(props: {
</div>
</Row>
)
}
})
function CommentStatus(props: {
contract: Contract

View File

@ -53,30 +53,30 @@ export function ContractGroupsList(props: {
/>
</Col>
)}
{groups.length === 0 && (
<Col className="ml-2 h-full justify-center text-gray-500">
No groups yet...
</Col>
)}
{groups.map((group) => (
<Row
key={group.id}
className={clsx('items-center justify-between gap-2 p-2')}
>
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
<Col className="h-96 overflow-auto">
{groups.length === 0 && (
<Col className="text-greyscale-4">No groups yet...</Col>
)}
{groups.map((group) => (
<Row
key={group.id}
className={clsx('items-center justify-between gap-2 p-2')}
>
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
</Row>
{user && canModifyGroupContracts(group, user.id) && (
<Button
color={'gray-white'}
size={'xs'}
onClick={() => removeContractFromGroup(group, contract)}
>
<XIcon className="text-greyscale-4 h-4 w-4" />
</Button>
)}
</Row>
{user && canModifyGroupContracts(group, user.id) && (
<Button
color={'gray-white'}
size={'xs'}
onClick={() => removeContractFromGroup(group, contract)}
>
<XIcon className="h-4 w-4 text-gray-500" />
</Button>
)}
</Row>
))}
))}
</Col>
</Col>
)
}

View File

@ -1,4 +1,3 @@
import clsx from 'clsx'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { groupPath } from 'web/lib/firebase/groups'
@ -87,10 +86,8 @@ export function CreateGroupButton(props: {
}}
submitBtn={{
label: 'Create',
className: clsx(
'normal-case',
isSubmitting ? 'loading btn-disabled' : ' btn-primary'
),
color: 'green',
isSubmitting,
}}
onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => {

View File

@ -3,7 +3,7 @@ import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { Group } from 'common/group'
import { deleteGroup, joinGroup } from 'web/lib/firebase/groups'
import { deleteGroup, joinGroup, updateGroup } from 'web/lib/firebase/groups'
import { Spacer } from '../layout/spacer'
import { useRouter } from 'next/router'
import { Modal } from 'web/components/layout/modal'
@ -31,6 +31,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
setIsSubmitting(true)
await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id)))
await updateGroup(group, { name })
setIsSubmitting(false)
updateOpen(false)

View File

@ -6,12 +6,13 @@ import { Spacer } from '../layout/spacer'
import { Group } from 'common/group'
import { deleteFieldFromGroup, updateGroup } from 'web/lib/firebase/groups'
import PencilIcon from '@heroicons/react/solid/PencilIcon'
import { DocumentRemoveIcon } from '@heroicons/react/solid'
import { TrashIcon } from '@heroicons/react/solid'
import { createPost } from 'web/lib/firebase/api'
import { Post } from 'common/post'
import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react'
import { usePost } from 'web/hooks/use-post'
import { Col } from '../layout/col'
export function GroupOverviewPost(props: {
group: Group
@ -43,6 +44,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
if (!editor) return
const newPost = {
title: group.name,
subtitle: 'About post for the group',
content: editor.getJSON(),
}
@ -98,35 +100,31 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
</p>
</div>
) : (
<div className="relative">
<div className="absolute top-0 right-0 z-10 space-x-2">
<Col>
<Content content={post.content} />
<Row className="place-content-end">
<Button
color="gray"
size="xs"
color="gray-white"
size="2xs"
onClick={() => {
setEditing(true)
editor?.commands.focus('end')
}}
>
<PencilIcon className="inline h-4 w-4" />
Edit
<PencilIcon className="inline h-5 w-5" />
</Button>
<Button
color="gray"
size="xs"
color="gray-white"
size="2xs"
onClick={() => {
deleteGroupAboutPost()
}}
>
<DocumentRemoveIcon className="inline h-4 w-4" />
Delete
<TrashIcon className="inline h-5 w-5 text-red-500" />
</Button>
</div>
<Content content={post.content} />
<Spacer h={2} />
</div>
</Row>
</Col>
)}
</>
)

View File

@ -36,8 +36,11 @@ import { CopyLinkButton } from '../copy-link-button'
import { REFERRAL_AMOUNT } from 'common/economy'
import toast from 'react-hot-toast'
import { ENV_CONFIG } from 'common/envs/constants'
import { PostCard } from '../post-card'
import { PostCard, PostCardList } from '../post-card'
import { LoadingIndicator } from '../loading-indicator'
import { useUser } from 'web/hooks/use-user'
import { CreatePost } from '../create-post'
import { Modal } from '../layout/modal'
const MAX_TRENDING_POSTS = 6
@ -59,7 +62,6 @@ export function GroupOverview(props: {
posts={posts}
isEditable={isEditable}
/>
{(group.aboutPostId != null || isEditable) && (
<>
<SectionHeader label={'About'} href={'/post/' + group.slug} />
@ -87,10 +89,55 @@ export function GroupOverview(props: {
user={user}
memberIds={memberIds}
/>
<GroupPosts group={group} posts={posts} />
</Col>
)
}
export function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser()
const createPost = (
<Modal size="xl" open={showCreatePost} setOpen={setShowCreatePost}>
<div className="w-full bg-white py-10">
<CreatePost group={group} />
</div>
</Modal>
)
const postList = (
<div className=" align-start w-full items-start">
<Row className="flex justify-between">
<Col>
<SectionHeader label={'Latest Posts'} />
</Col>
<Col>
{user && (
<Button
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post
</Button>
)}
</Col>
</Row>
<div className="mt-2">
<PostCardList posts={posts} />
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</div>
)
return showCreatePost ? createPost : postList
}
function GroupOverviewPinned(props: {
group: Group
posts: Post[]

View File

@ -163,7 +163,7 @@ export function GroupSelector(props: {
user={creator}
onOpenStateChange={setIsCreatingNewGroup}
className={
'w-full justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white'
'flex w-full flex-row items-center justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white'
}
label={'Create a new Group'}
addGroupIdParamOnSubmit

View File

@ -13,6 +13,7 @@ import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { firebaseLogin } from 'web/lib/firebase/users'
import { GroupLinkItem } from 'web/pages/groups'
import toast from 'react-hot-toast'
import { Button } from '../button'
export function GroupsButton(props: { user: User; className?: string }) {
const { user, className } = props
@ -92,23 +93,22 @@ export function JoinOrLeaveGroupButton(props: {
group: Group
isMember: boolean
user: User | undefined | null
small?: boolean
className?: string
}) {
const { group, small, className, isMember, user } = props
const smallStyle =
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
const { group, className, isMember, user } = props
if (!user) {
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
<button
<Button
size="xs"
color="blue"
onClick={firebaseLogin}
className={clsx('btn btn-sm', small && smallStyle, className)}
className={className}
>
Login to follow
</button>
</Button>
)
}
const onJoinGroup = () => {
@ -124,27 +124,27 @@ export function JoinOrLeaveGroupButton(props: {
if (isMember) {
return (
<button
className={clsx(
'btn btn-outline btn-xs',
small && smallStyle,
className
)}
<Button
size="xs"
color="gray-white"
className={`${className} border-greyscale-4 border !border-solid`}
onClick={withTracking(onLeaveGroup, 'leave group')}
>
Unfollow
</button>
</Button>
)
}
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
<button
className={clsx('btn btn-sm', small && smallStyle, className)}
<Button
size="xs"
color="blue"
className={className}
onClick={withTracking(onJoinGroup, 'join group')}
>
Follow
</button>
</Button>
)
}

View File

@ -63,7 +63,8 @@ export default function Sidebar(props: {
)}
<Spacer h={6} />
{!user && <SignInButton className="mb-4" />}
{user === undefined && <div className="h-[178px]" />}
{user === null && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}

View File

@ -73,13 +73,6 @@ export function NumericResolutionPanel(props: {
setIsSubmitting(false)
}
const submitButtonClass =
outcomeMode === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: outcome !== undefined
? 'btn-primary'
: 'btn-disabled'
return (
<Col
className={clsx(
@ -129,7 +122,6 @@ export function NumericResolutionPanel(props: {
onResolve={resolve}
isSubmitting={isSubmitting}
openModalButtonClass={clsx('w-full mt-2')}
submitButtonClass={submitButtonClass}
color={outcomeMode === 'CANCEL' ? 'yellow' : 'indigo'}
disabled={outcomeMode === undefined}
/>

View File

@ -2,7 +2,6 @@ import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useState } from 'react'
import { PostCardList } from 'web/pages/group/[...slugs]'
import { Button } from './button'
import { PillButton } from './buttons/pill-button'
import { ContractSearch } from './contract-search'
@ -10,6 +9,7 @@ import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
import { PostCardList } from './post-card'
export function PinnedSelectModal(props: {
title: string

View File

@ -18,8 +18,8 @@ const MARGIN_Y = MARGIN.top + MARGIN.bottom
export type GraphMode = 'profit' | 'value'
export const PortfolioTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { mouseX, xScale } = props
const d = dayjs(xScale.invert(mouseX))
const { x, xScale } = props
const d = dayjs(xScale.invert(x))
return (
<Col className="text-xs font-semibold sm:text-sm">
<div>{d.format('MMM/D/YY')}</div>

View File

@ -32,10 +32,10 @@ export const PortfolioValueSection = memo(
return (
<>
<Row className="mb-2 justify-between">
<Row className="gap-4 sm:gap-8">
<Row className="gap-2">
<Col
className={clsx(
'cursor-pointer',
'w-24 cursor-pointer sm:w-28 ',
graphMode != 'profit'
? 'cursor-pointer opacity-40 hover:opacity-80'
: ''
@ -72,7 +72,7 @@ export const PortfolioValueSection = memo(
<Col
className={clsx(
'cursor-pointer',
'w-24 cursor-pointer sm:w-28',
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
)}
onClick={() => {

View File

@ -1,4 +1,5 @@
import { track } from '@amplitude/analytics-browser'
import { DocumentIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { Post } from 'common/post'
import Link from 'next/link'
@ -6,8 +7,8 @@ import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar'
import { Card } from './card'
import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link'
export function PostCard(props: {
@ -25,9 +26,9 @@ export function PostCard(props: {
return (
<div className="relative py-1">
<Row
<Card
className={clsx(
' relative gap-3 rounded-lg bg-white py-2 shadow-md hover:cursor-pointer hover:bg-gray-100',
'relative flex gap-3 py-2 px-3',
itemIds?.includes(post.id) && highlightClassName
)}
>
@ -44,9 +45,20 @@ export function PostCard(props: {
<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 className=" break-words text-lg font-medium text-gray-900">
{post.title}
</div>
<div className="font-small text-md break-words text-gray-500">
{post.subtitle}
</div>
</div>
</Row>
<div>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<DocumentIcon className={'h3 w-3'} />
Post
</span>
</div>
</Card>
{onPostClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
@ -80,3 +92,23 @@ export function PostCard(props: {
</div>
)
}
export function PostCardList(props: {
posts: Post[]
highlightOptions?: CardHighlightOptions
onPostClick?: (post: Post) => void
}) {
const { posts, onPostClick, highlightOptions } = props
return (
<div className="w-full">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onPostClick={onPostClick}
highlightOptions={highlightOptions}
/>
))}
</div>
)
}

View File

@ -1,15 +1,22 @@
import { DateTimeTooltip } from './datetime-tooltip'
import React from 'react'
import React, { useEffect, useState } from 'react'
import { fromNow } from 'web/lib/util/time'
export function RelativeTimestamp(props: { time: number }) {
const { time } = props
const [isClient, setIsClient] = useState(false)
useEffect(() => {
// Only render on client to prevent difference from server.
setIsClient(true)
}, [])
return (
<DateTimeTooltip
className="ml-1 whitespace-nowrap text-gray-400"
time={time}
>
{fromNow(time)}
{isClient ? fromNow(time) : ''}
</DateTimeTooltip>
)
}

View File

@ -1,6 +1,5 @@
import React from 'react'
import { CodeIcon } from '@heroicons/react/outline'
import { Menu } from '@headlessui/react'
import toast from 'react-hot-toast'
import { Contract } from 'common/contract'
@ -8,6 +7,7 @@ import { contractPath } from 'web/lib/firebase/contracts'
import { DOMAIN } from 'common/envs/constants'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { Button } from './button'
export function embedContractCode(contract: Contract) {
const title = contract.question
@ -15,6 +15,7 @@ export function embedContractCode(contract: Contract) {
return `<iframe src="${src}" title="${title}" frameborder="0"></iframe>`
}
// TODO: move this function elsewhere
export function embedContractGridCode(contracts: Contract[]) {
const height = (contracts.length - (contracts.length % 2)) * 100 + 'px'
const src = `https://${DOMAIN}/embed/grid/${contracts
@ -26,24 +27,21 @@ export function embedContractGridCode(contracts: Contract[]) {
export function ShareEmbedButton(props: { contract: Contract }) {
const { contract } = props
const codeIcon = <CodeIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
const codeIcon = <CodeIcon className="h-4 w-4" aria-hidden />
return (
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
<Button
size="2xs"
color="gray-outline"
className="gap-1"
onClick={() => {
copyToClipboard(embedContractCode(contract))
toast.success('Embed code copied!', {
icon: codeIcon,
})
toast.success('Embed code copied!', { icon: codeIcon })
track('copy embed code')
}}
>
<Menu.Button className="btn btn-xs border-2 !border-gray-500 !bg-white normal-case text-gray-500">
{codeIcon}
Embed
</Menu.Button>
</Menu>
{codeIcon}
Embed
</Button>
)
}

View File

@ -1,13 +1,8 @@
import { LinkIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Title } from './title'
import { Button } from './button'
import { TweetButton } from './tweet-button'
import { Row } from './layout/row'
import { CopyLinkButton } from './copy-link-button'
export function SharePostModal(props: {
shareUrl: string
@ -16,30 +11,14 @@ export function SharePostModal(props: {
}) {
const { isOpen, setOpen, shareUrl } = props
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
return (
<Modal open={isOpen} setOpen={setOpen} size="md">
<Col className="gap-4 rounded bg-white p-4">
<Title className="!mt-0 !mb-2" text="Share this post" />
<Button
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
onClick={() => {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: linkIcon,
})
track('copy share post link')
}}
>
{linkIcon} Copy link
</Button>
<Row className="z-0 justify-start gap-4 self-center">
<TweetButton className="self-start" tweetText={shareUrl} />
</Row>
<CopyLinkButton url={shareUrl} tracking="copy share post link" />
<div className="self-center">
<TweetButton tweetText={shareUrl} />
</div>
</Col>
</Modal>
)

View File

@ -29,7 +29,14 @@ export const SizedContainer = (props: {
}, [threshold, fullHeight, mobileHeight])
return (
<div ref={containerRef}>
{width != null && height != null && children(width, height)}
{width != null && height != null ? (
children(width, height)
) : (
<>
<div className="sm:hidden" style={{ height: mobileHeight }} />
<div className="hidden sm:flex" style={{ height: fullHeight }} />
</>
)}
</div>
)
}

View File

@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { debounce, sum } from 'lodash'
import { debounce } from 'lodash'
import { Comment } from 'common/comment'
import { User } from 'common/user'
import { CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
@ -13,25 +12,27 @@ import { Row } from './layout/row'
import { LIKE_TIP_AMOUNT } from 'common/like'
import { formatMoney } from 'common/util/format'
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop
export function Tipper(prop: {
comment: Comment
myTip: number
totalTip: number
}) {
const { comment, myTip, totalTip } = prop
const me = useUser()
const myId = me?.id ?? ''
const savedTip = tips[myId] ?? 0
const [localTip, setLocalTip] = useState(savedTip)
const [localTip, setLocalTip] = useState(myTip)
// listen for user being set
const initialized = useRef(false)
useEffect(() => {
if (tips[myId] && !initialized.current) {
setLocalTip(tips[myId])
if (myTip && !initialized.current) {
setLocalTip(myTip)
initialized.current = true
}
}, [tips, myId])
}, [myTip])
const total = sum(Object.values(tips)) - savedTip + localTip
const total = totalTip - myTip + localTip
// declare debounced function only on first render
const [saveTip] = useState(() =>
@ -73,7 +74,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const addTip = (delta: number) => {
setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta)
me && saveTip(me, comment, localTip - myTip + delta)
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
}

View File

@ -1,22 +1,23 @@
import clsx from 'clsx'
import TwitterLogo from 'web/lib/icons/twitter-logo'
import { trackCallback } from 'web/lib/service/analytics'
import { buttonClass } from './button'
export function TweetButton(props: { className?: string; tweetText: string }) {
const { tweetText, className } = props
export function TweetButton(props: { tweetText: string }) {
const { tweetText } = props
return (
<a
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
style={{
backgroundColor: 'white',
border: '2px solid #1da1f2',
color: '#1da1f2',
}}
// #1da1f2 is twitter blue
className={clsx(
buttonClass('2xs', 'override'),
'gap-1 border-2 border-[#1da1f2] text-[#1da1f2] hover:bg-[#1da1f2] hover:text-white'
)}
href={getTweetHref(tweetText)}
onClick={trackCallback('share tweet')}
target="_blank"
>
<img className="mr-2" src={'/twitter-logo.svg'} width={15} height={15} />
<TwitterLogo width={15} height={15} />
<div>Tweet</div>
</a>
)

View File

@ -3,12 +3,10 @@ import Router from 'next/router'
import { useEffect, useState } from 'react'
import { getProbability } from 'common/calculate'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { CPMMBinaryContract } from 'common/contract'
import { Customize, USAMap } from './usa-map'
import {
getContractFromSlug,
listenForContract,
} from 'web/lib/firebase/contracts'
import { listenForContract } from 'web/lib/firebase/contracts'
import { interpolateColor } from 'common/util/color'
export interface StateElectionMarket {
creatorUsername: string
@ -17,10 +15,14 @@ export interface StateElectionMarket {
state: string
}
export function StateElectionMap(props: { markets: StateElectionMarket[] }) {
export function StateElectionMap(props: {
markets: StateElectionMarket[]
contracts: CPMMBinaryContract[]
}) {
const { markets } = props
const [contracts, setContracts] = useState(props.contracts)
useUpdateContracts(contracts, setContracts)
const contracts = useContracts(markets.map((m) => m.slug))
const probs = contracts.map((c) =>
c ? getProbability(c as CPMMBinaryContract) : 0.5
)
@ -45,35 +47,30 @@ export function StateElectionMap(props: { markets: StateElectionMarket[] }) {
const probToColor = (prob: number, isWinRepublican: boolean) => {
const p = isWinRepublican ? prob : 1 - prob
const hue = p > 0.5 ? 350 : 240
const saturation = 100
const lightness = 100 - 50 * Math.abs(p - 0.5)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
const color = p > 0.5 ? '#e4534b' : '#5f6eb0'
return interpolateColor('#ebe4ec', color, Math.abs(p - 0.5) * 2)
}
const useContracts = (slugs: string[]) => {
const [contracts, setContracts] = useState<(Contract | undefined)[]>(
slugs.map(() => undefined)
)
useEffect(() => {
Promise.all(slugs.map((slug) => getContractFromSlug(slug))).then(
(contracts) => setContracts(contracts)
)
}, [slugs])
const useUpdateContracts = (
contracts: CPMMBinaryContract[],
setContracts: (newContracts: CPMMBinaryContract[]) => void
) => {
useEffect(() => {
if (contracts.some((c) => c === undefined)) return
// listen to contract updates
const unsubs = (contracts as Contract[]).map((c, i) =>
listenForContract(
c.id,
(newC) => newC && setContracts(setAt(contracts, i, newC))
const unsubs = contracts
.filter((c) => !!c)
.map((c, i) =>
listenForContract(
c.id,
(newC) =>
newC &&
setContracts(setAt(contracts, i, newC as CPMMBinaryContract))
)
)
)
return () => unsubs.forEach((u) => u())
}, [contracts])
}, [contracts, setContracts])
return contracts
}

View File

@ -1,6 +1,7 @@
// https://github.com/jb-1980/usa-map-react
// MIT License
import clsx from 'clsx'
import { DATA } from './data'
import { USAState } from './usa-state'
@ -53,8 +54,6 @@ export const USAMap = ({
onClick = (e) => {
console.log(e.currentTarget.dataset.name)
},
width = 959,
height = 593,
title = 'US states map',
defaultFill = '#d3d3d3',
customize,
@ -68,10 +67,8 @@ export const USAMap = ({
return (
<svg
className={className}
className={clsx('flex h-96 w-full sm:h-full', className)}
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 959 593"
>
<title>{title}</title>

View File

@ -11,12 +11,13 @@ export function WarningConfirmationButton(props: {
amount: number | undefined
marketType: 'freeResponse' | 'binary'
warning?: string
onSubmit: () => void
onSubmit?: () => void
disabled: boolean
isSubmitting: boolean
openModalButtonClass?: string
color: ColorType
size: SizeType
actionLabel: string
}) {
const {
amount,
@ -27,8 +28,15 @@ export function WarningConfirmationButton(props: {
openModalButtonClass,
size,
color,
actionLabel,
} = props
const buttonText = isSubmitting
? 'Submitting...'
: amount
? `${actionLabel} ${formatMoney(amount)}`
: actionLabel
if (!warning) {
return (
<Button
@ -38,11 +46,7 @@ export function WarningConfirmationButton(props: {
onClick={onSubmit}
color={color}
>
{isSubmitting
? 'Submitting...'
: amount
? `Wager ${formatMoney(amount)}`
: 'Wager'}
{buttonText}
</Button>
)
}
@ -50,18 +54,18 @@ export function WarningConfirmationButton(props: {
return (
<ConfirmationButton
openModalBtn={{
label: amount ? `Wager ${formatMoney(amount)}` : 'Wager',
label: buttonText,
size: size,
color: 'yellow',
disabled: isSubmitting,
disabled: isSubmitting || disabled,
}}
cancelBtn={{
label: 'Cancel',
className: 'btn btn-warning',
color: 'yellow',
}}
submitBtn={{
label: 'Submit',
className: clsx('btn border-none btn-sm btn-ghost self-center'),
color: 'gray',
}}
onSubmit={onSubmit}
>

View File

@ -244,7 +244,7 @@ function Button(props: {
type="button"
className={clsx(
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
color === 'green' && 'bg-teal-500 bg-teal-600 text-white',
color === 'green' && 'bg-teal-500 text-white hover:bg-teal-600',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',

View File

@ -92,7 +92,7 @@ export const useContractsByDailyScoreGroups = (
const q = new QueryClient()
export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
q.fetchQuery(['contracts'], () => listAllContracts(10000), {
staleTime: Infinity,
})

View File

@ -91,9 +91,15 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => {
const nextQ = lastDoc
? query(state.baseQ, startAfter(lastDoc), limit(state.pageSize))
: query(state.baseQ, limit(state.pageSize))
return onSnapshot(nextQ, (snapshot) => {
dispatch({ type: 'LOAD', snapshot })
})
return onSnapshot(
nextQ,
(snapshot) => {
dispatch({ type: 'LOAD', snapshot })
},
(error) => {
console.error('error', error)
}
)
}
}, [state.isLoading, state.baseQ, state.docs, state.pageSize])

View File

@ -1,12 +1,13 @@
import { useEffect } from 'react'
import { useStateCheckEquality } from './use-state-check-equality'
import { NextRouter } from 'next/router'
import { NextRouter, useRouter } from 'next/router'
export type PersistenceOptions<T> = { key: string; store: PersistentStore<T> }
export interface PersistentStore<T> {
get: (k: string) => T | undefined
set: (k: string, v: T | undefined) => void
readsUrl?: boolean
}
const withURLParam = (location: Location, k: string, v?: string) => {
@ -61,6 +62,7 @@ export const urlParamStore = (router: NextRouter): PersistentStore<string> => ({
window.history.replaceState(updatedState, '', url)
}
},
readsUrl: true,
})
export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({
@ -102,5 +104,20 @@ export const usePersistentState = <T>(
store.set(key, state)
}
}, [key, state])
if (store?.readsUrl) {
// On page load, router isn't ready immediately, so set state once it is.
// eslint-disable-next-line react-hooks/rules-of-hooks
const router = useRouter()
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (router.isReady) {
const savedValue = key != null ? store.get(key) : undefined
setState(savedValue ?? initial)
}
}, [router.isReady])
}
return [state, setState] as const
}

View File

@ -108,7 +108,6 @@ export const tournamentContractsByGroupSlugQuery = (slug: string) =>
query(
contracts,
where('groupSlugs', 'array-contains', slug),
where('isResolved', '==', false),
orderBy('popularityScore', 'desc')
)

View File

@ -0,0 +1,28 @@
export default function TwitterLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
version="1.1"
id="Logo"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 248 204"
stroke="currentColor"
fill="currentColor"
{...props}
>
<g id="Logo_1_">
<path
id="white_background"
className="st0"
d="M221.95,51.29c0.15,2.17,0.15,4.34,0.15,6.53c0,66.73-50.8,143.69-143.69,143.69v-0.04
C50.97,201.51,24.1,193.65,1,178.83c3.99,0.48,8,0.72,12.02,0.73c22.74,0.02,44.83-7.61,62.72-21.66
c-21.61-0.41-40.56-14.5-47.18-35.07c7.57,1.46,15.37,1.16,22.8-0.87C27.8,117.2,10.85,96.5,10.85,72.46c0-0.22,0-0.43,0-0.64
c7.02,3.91,14.88,6.08,22.92,6.32C11.58,63.31,4.74,33.79,18.14,10.71c25.64,31.55,63.47,50.73,104.08,52.76
c-4.07-17.54,1.49-35.92,14.61-48.25c20.34-19.12,52.33-18.14,71.45,2.19c11.31-2.23,22.15-6.38,32.07-12.26
c-3.77,11.69-11.66,21.62-22.2,27.93c10.01-1.18,19.79-3.86,29-7.95C240.37,35.29,231.83,44.14,221.95,51.29z"
/>
</g>
</svg>
)
}

View File

@ -5,30 +5,32 @@
import { ENV_CONFIG } from 'common/envs/constants'
import { PROD_CONFIG } from 'common/envs/prod'
try {
;(function (l, e, a, p) {
if (window.Sprig) return
window.Sprig = function (...args) {
S._queue.push(args)
}
const S = window.Sprig
S.appId = a
S._queue = []
window.UserLeap = S
a = l.createElement('script')
a.async = 1
a.src = e + '?id=' + S.appId
p = l.getElementsByTagName('script')[0]
ENV_CONFIG.domain === PROD_CONFIG.domain && p.parentNode.insertBefore(a, p)
})(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId)
} catch (error) {
console.log('Error initializing Sprig, please complain to Barak', error)
if (ENV_CONFIG.domain === PROD_CONFIG.domain) {
try {
;(function (l, e, a, p) {
if (window.Sprig) return
window.Sprig = function (...args) {
S._queue.push(args)
}
const S = window.Sprig
S.appId = a
S._queue = []
window.UserLeap = S
a = l.createElement('script')
a.async = 1
a.src = e + '?id=' + S.appId
p = l.getElementsByTagName('script')[0]
p.parentNode.insertBefore(a, p)
})(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId)
} catch (error) {
console.log('Error initializing Sprig, please complain to Barak', error)
}
}
export function setUserId(userId: string): void {
window.Sprig('setUserId', userId)
if (window.Sprig) window.Sprig('setUserId', userId)
}
export function setAttributes(attributes: Record<string, unknown>): void {
window.Sprig('setAttributes', attributes)
if (window.Sprig) window.Sprig('setAttributes', attributes)
}

View File

@ -22,6 +22,7 @@
"@amplitude/analytics-browser": "0.4.1",
"@floating-ui/react-dom-interactions": "0.9.2",
"@headlessui/react": "1.6.1",
"@hello-pangea/dnd": "16.0.0",
"@heroicons/react": "1.0.6",
"@react-query-firebase/firestore": "0.4.2",
"@tiptap/core": "2.0.0-beta.182",
@ -54,11 +55,10 @@
"next": "12.3.1",
"node-fetch": "3.2.4",
"prosemirror-state": "1.4.1",
"react": "17.0.2",
"react-beautiful-dnd": "13.1.1",
"react": "18.2.0",
"react-confetti": "6.0.1",
"react-dom": "17.0.2",
"react-expanding-textarea": "2.3.5",
"react-dom": "18.2.0",
"react-expanding-textarea": "2.3.6",
"react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1",
"react-masonry-css": "1.0.16",
@ -75,9 +75,8 @@
"@types/d3": "7.4.0",
"@types/lodash": "4.14.178",
"@types/node": "16.11.11",
"@types/react": "17.0.43",
"@types/react-beautiful-dnd": "13.1.2",
"@types/react-dom": "17.0.2",
"@types/react": "18.0.21",
"@types/react-dom": "18.0.6",
"@types/string-similarity": "^4.0.0",
"autoprefixer": "10.2.6",
"critters": "0.0.16",

View File

@ -207,14 +207,18 @@ export function ContractPageContent(
return (
<Page
rightSidebar={
<>
<ContractPageSidebar contract={contract} />
{isCreator && (
<Col className={'xl:hidden'}>
<RecommendedContractsWidget contract={contract} />
</Col>
)}
</>
user || user === null ? (
<>
<ContractPageSidebar contract={contract} />
{isCreator && (
<Col className={'xl:hidden'}>
<RecommendedContractsWidget contract={contract} />
</Col>
)}
</>
) : (
<div />
)
}
>
{showConfetti && (

View File

@ -42,7 +42,7 @@ function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
`}
</Script>
<Head>
<title>Manifold Markets A market for every question</title>
<title>{'Manifold Markets — A market for every question'}</title>
<meta
property="og:title"

View File

@ -9,6 +9,7 @@ import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Button } from 'web/components/button'
export const getServerSideProps = redirectIfLoggedOut('/')
@ -33,15 +34,15 @@ export default function AddFundsPage() {
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<Title className="!mt-0" text="Get Mana" />
<img
className="mb-6 block -scale-x-100 self-center"
src="/stylized-crane-black.png"
className="mb-6 block self-center"
src="/welcome/manipurple.png"
width={200}
height={200}
/>
<div className="mb-6 text-gray-500">
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
Buy mana (M$) to trade in your favorite markets. <br />{' '}
<i>Not redeemable for cash.</i>
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
@ -63,13 +64,15 @@ export default function AddFundsPage() {
method="POST"
className="mt-8"
>
<button
<Button
type="submit"
className="btn btn-primary w-full bg-gradient-to-r from-indigo-500 to-blue-500 font-medium hover:from-indigo-600 hover:to-blue-600"
color="gradient"
size="xl"
className="w-full"
onClick={trackCallback('checkout', { amount: amountSelected })}
>
Checkout
</button>
</Button>
</form>
</Col>
</Col>

View File

@ -38,6 +38,7 @@ import { ExternalLinkIcon } from '@heroicons/react/outline'
import { SiteLink } from 'web/components/site-link'
import { Button } from 'web/components/button'
import { AddFundsModal } from 'web/components/add-funds-modal'
import ShortToggle from 'web/components/widgets/short-toggle'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -50,6 +51,7 @@ type NewQuestionParams = {
description: string
closeTime: string
outcomeType: string
visibility: string
// Params for PSEUDO_NUMERIC outcomeType
min?: string
max?: string
@ -136,7 +138,9 @@ export function NewContract(props: {
const [maxString, setMaxString] = useState(params?.max ?? '')
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
const [initialValueString, setInitialValueString] = useState(initValue)
const [visibility, setVisibility] = useState<visibility>(
(params?.visibility as visibility) ?? 'public'
)
// for multiple choice, init to 3 empty answers
const [answers, setAnswers] = useState(['', '', ''])
@ -168,7 +172,6 @@ export function NewContract(props: {
undefined
)
const [showGroupSelector, setShowGroupSelector] = useState(true)
const [visibility, setVisibility] = useState<visibility>('public')
const [fundsModalOpen, setFundsModalOpen] = useState(false)
@ -414,14 +417,9 @@ export function NewContract(props: {
<Row className="form-control my-2 items-center gap-2 text-sm">
<span>Display this market on homepage</span>
<input
type="checkbox"
checked={visibility === 'public'}
disabled={isSubmitting}
className="cursor-pointer"
onChange={(e) =>
setVisibility(e.target.checked ? 'public' : 'unlisted')
}
<ShortToggle
on={visibility === 'public'}
setOn={(on) => setVisibility(on ? 'public' : 'unlisted')}
/>
</Row>

View File

@ -23,6 +23,7 @@ export default function CreateDateDocPage() {
})
const title = `${user?.name}'s Date Doc`
const subtitle = 'Manifold dating docs'
const [birthday, setBirthday] = useState<undefined | string>(undefined)
const [question, setQuestion] = useState(
'Will I find a partner in the next 3 months?'
@ -46,6 +47,7 @@ export default function CreateDateDocPage() {
'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug'
> & { question: string } = {
title,
subtitle,
content: editor.getJSON(),
bounty: 0,
birthday: birthdayTime,

View File

@ -42,11 +42,7 @@ 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 { Title } from 'web/components/title'
import { CreatePost } from 'web/components/create-post'
import { GroupOverview } from 'web/components/groups/group-overview'
import { CardHighlightOptions } from 'web/components/contract/contracts-grid'
import { PostCard } from 'web/components/post-card'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -183,16 +179,6 @@ export default function GroupPage(props: {
</Col>
)
const postsPage = (
<>
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{posts && <GroupPosts posts={groupPosts} group={group} />}
</div>
</Col>
</>
)
const overviewPage = (
<>
<GroupOverview
@ -249,10 +235,6 @@ export default function GroupPage(props: {
title: 'Leaderboards',
content: leaderboardTab,
},
{
title: 'Posts',
content: postsPage,
},
]
return (
@ -336,63 +318,6 @@ function GroupLeaderboard(props: {
)
}
export 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">
<PostCardList posts={posts} />
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</div>
)
return showCreatePost ? createPost : postList
}
export function PostCardList(props: {
posts: Post[]
highlightOptions?: CardHighlightOptions
onPostClick?: (post: Post) => void
}) {
const { posts, onPostClick, highlightOptions } = props
return (
<div className="w-full">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onPostClick={onPostClick}
highlightOptions={highlightOptions}
/>
))}
</div>
)
}
function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props
const [open, setOpen] = useState(false)

View File

@ -21,7 +21,6 @@ import { Group } from 'common/group'
import { SiteLink } from 'web/components/site-link'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
useMemberGroupIds,
useMemberGroupsSubscription,
useTrendingGroups,
} from 'web/hooks/use-group'
@ -80,6 +79,7 @@ export default function Home() {
const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6)
const groups = useMemberGroupsSubscription(user)
const trendingGroups = useTrendingGroups()
const groupContracts = useContractsByDailyScoreGroups(
groups?.map((g) => g.slug)
)
@ -113,16 +113,26 @@ export default function Home() {
<LoadingIndicator />
) : (
<>
{renderSections(user, sections, {
{renderSections(sections, {
score: trendingContracts,
newest: newContracts,
'daily-trending': dailyTrendingContracts,
'daily-movers': dailyMovers,
})}
<TrendingGroupsSection user={user} />
{renderGroupSections(user, groups, groupContracts)}
{groups && groupContracts && trendingGroups.length > 0 ? (
<>
<TrendingGroupsSection
className="mb-4"
user={user}
myGroups={groups}
trendingGroups={trendingGroups}
/>
{renderGroupSections(user, groups, groupContracts)}
</>
) : (
<LoadingIndicator />
)}
</>
)}
</Col>
@ -171,7 +181,6 @@ export const getHomeItems = (sections: string[]) => {
}
function renderSections(
user: User,
sections: { id: string; label: string }[],
sectionContracts: {
'daily-movers': CPMMBinaryContract[]
@ -192,7 +201,7 @@ function renderSections(
}
if (id === 'daily-trending') {
return (
<ContractsSection
<SearchSection
key={id}
label={label}
contracts={contracts}
@ -202,11 +211,11 @@ function renderSections(
)
}
return (
<ContractsSection
<SearchSection
key={id}
label={label}
contracts={contracts}
sort={id === 'daily-trending' ? 'daily-score' : (id as Sort)}
sort={id as Sort}
/>
)
})}
@ -216,13 +225,9 @@ function renderSections(
function renderGroupSections(
user: User,
groups: Group[] | undefined,
groupContracts: Dictionary<CPMMBinaryContract[]> | undefined
groups: Group[],
groupContracts: Dictionary<CPMMBinaryContract[]>
) {
if (!groups || !groupContracts) {
return <LoadingIndicator />
}
const filteredGroups = groups.filter((g) => groupContracts[g.slug])
const orderedGroups = sortBy(filteredGroups, (g) =>
// Sort by sum of top two daily scores.
@ -285,7 +290,7 @@ function SectionHeader(props: {
)
}
function ContractsSection(props: {
function SearchSection(props: {
label: string
contracts: CPMMBinaryContract[]
sort: Sort
@ -406,15 +411,16 @@ function DailyStats(props: {
}
export function TrendingGroupsSection(props: {
user: User | null | undefined
user: User
myGroups: Group[]
trendingGroups: Group[]
className?: string
}) {
const { user, className } = props
const memberGroupIds = useMemberGroupIds(user) || []
const { user, myGroups, trendingGroups, className } = props
const groups = useTrendingGroups().filter(
(g) => !memberGroupIds.includes(g.id)
)
const myGroupIds = new Set(myGroups.map((g) => g.id))
const groups = trendingGroups.filter((g) => !myGroupIds.has(g.id))
const count = 20
const chosenGroups = groups.slice(0, count)
@ -433,10 +439,9 @@ export function TrendingGroupsSection(props: {
<PillButton
className="flex flex-row items-center gap-1"
key={g.id}
selected={memberGroupIds.includes(g.id)}
selected={myGroupIds.has(g.id)}
onSelect={() => {
if (!user) return
if (memberGroupIds.includes(g.id)) leaveGroup(g, user?.id)
if (myGroupIds.has(g.id)) leaveGroup(g, user.id)
else {
const homeSections = (user.homeSections ?? [])
.filter((id) => id !== g.id)

View File

@ -44,8 +44,8 @@ export default function LabsPage() {
/>
<LabCard
title="💌 Dating docs"
description="Browse dating docs or create your own"
title="💌 Dating"
description="Browse dating profiles and bet on relationships"
href="/date-docs"
/>

View File

@ -1,11 +1,14 @@
import { CPMMBinaryContract } from 'common/contract'
import { Col } from 'web/components/layout/col'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import {
StateElectionMarket,
StateElectionMap,
} from 'web/components/usa-map/state-election-map'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
const senateMidterms: StateElectionMarket[] = [
{
@ -175,76 +178,94 @@ const governorMidterms: StateElectionMarket[] = [
},
]
const App = () => {
export async function getStaticProps() {
const senateContracts = await Promise.all(
senateMidterms.map((m) =>
getContractFromSlug(m.slug).then((c) => c ?? null)
)
)
const governorContracts = await Promise.all(
governorMidterms.map((m) =>
getContractFromSlug(m.slug).then((c) => c ?? null)
)
)
return {
props: { senateContracts, governorContracts },
revalidate: 60, // regenerate after a minute
}
}
const App = (props: {
senateContracts: CPMMBinaryContract[]
governorContracts: CPMMBinaryContract[]
}) => {
const { senateContracts, governorContracts } = props
return (
<Page className="">
<Col className="items-center justify-center">
<Title text="2022 US Midterm Elections" className="mt-2" />
<SEO
title="2022 US Midterm Elections"
description="Bet on the midterm elections using prediction markets. See Manifold's state-by-state breakdown of senate and governor races."
/>
<div className="mt-2 text-2xl">Senate</div>
<StateElectionMap markets={senateMidterms} />
<StateElectionMap
markets={senateMidterms}
contracts={senateContracts}
/>
<iframe
src="https://manifold.markets/TomShlomi/will-the-gop-control-the-us-senate"
title="Will the Democrats control the Senate after the Midterms?"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<Spacer h={8} />
<div className="mt-8 text-2xl">Governors</div>
<StateElectionMap markets={governorMidterms} />
<StateElectionMap
markets={governorMidterms}
contracts={governorContracts}
/>
<iframe
src="https://manifold.markets/ManifoldMarkets/democrats-go-down-at-least-one-gove"
title="Democrats go down at least one governor on net in 2022"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<Spacer h={8} />
<div className="mt-8 text-2xl">House</div>
<iframe
src="https://manifold.markets/BoltonBailey/will-democrats-maintain-control-of"
title="Will the Democrats control the House after the Midterms?"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<Spacer h={8} />
<div className="mt-8 text-2xl">Related markets</div>
<iframe
src="https://manifold.markets/BoltonBailey/balance-of-power-in-us-congress-aft"
title="Balance of Power in US Congress after 2022 Midterms"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<iframe
src="https://manifold.markets/SG/will-a-democrat-win-the-2024-us-pre"
title="Will a Democrat win the 2024 US presidential election?"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<iframe
src="https://manifold.markets/Ibozz91/will-the-2022-alaska-house-general"
title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
<iframe
src="https://manifold.markets/NathanpmYoung/how-many-supreme-court-justices-wil-1e597c3853ad"
title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?"
frameBorder="0"
width={800}
height={400}
className="mt-8"
className="mt-8 flex h-96 w-full"
></iframe>
</Col>
</Page>

View File

@ -38,6 +38,7 @@ import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/economy'
import { groupBy, sum, uniqBy } from 'lodash'
@ -440,7 +441,8 @@ function IncomeNotificationItem(props: {
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') {
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
if (sourceText && +sourceText === BETTING_STREAK_BONUS_MAX)
reasonText = '(max) for your'
else reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested predictions returned as a`

View File

@ -25,6 +25,7 @@ import { useCommentsOnPost } from 'web/hooks/use-comments'
import { useUser } from 'web/hooks/use-user'
import { usePost } from 'web/hooks/use-post'
import { SEO } from 'web/components/SEO'
import { Subtitle } from 'web/components/subtitle'
export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
@ -75,7 +76,11 @@ export default function PostPage(props: {
url={'/post/' + post.slug}
/>
<div className="mx-auto w-full max-w-3xl ">
<Title className="!mt-0 py-4 px-2" text={post.title} />
<div>
<Title className="!my-0 px-2 pt-4" text={post.title} />
<br />
<Subtitle className="!mt-2 px-2 pb-4" text={post.subtitle} />
</div>
<Row>
<Col className="flex-1 px-2">
<div className={'inline-flex'}>
@ -202,25 +207,20 @@ export function RichEditPost(props: { post: Post }) {
</Row>
</>
) : (
<>
<div className="relative">
<div className="absolute top-0 right-0 z-10 space-x-2">
<Button
color="gray"
size="xs"
onClick={() => {
setEditing(true)
editor?.commands.focus('end')
}}
>
<PencilIcon className="inline h-4 w-4" />
Edit
</Button>
</div>
<Content content={post.content} />
<Spacer h={2} />
</div>
</>
<Col>
<Content content={post.content} />
<Row className="place-content-end">
<Button
color="gray-white"
size="xs"
onClick={() => {
setEditing(true)
editor?.commands.focus('end')
}}
>
<PencilIcon className="inline h-4 w-4" />
</Button>
</Row>
</Col>
)
}

View File

@ -215,7 +215,6 @@ export default function ProfilePage(props: {
}}
submitBtn={{
label: 'Update key',
className: 'btn-primary',
}}
onSubmitWithSuccess={async () => {
updateApiKey()

View File

@ -4,7 +4,7 @@ import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
import { listAllContracts } from 'web/lib/firebase/contracts'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const contracts = await listAllContracts(1000, undefined, 'popularityScore')
const contracts = await listAllContracts(5000, undefined, 'popularityScore')
const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
@ -14,7 +14,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
priority: score(market.popularityScore ?? 0),
lastmod: market.lastUpdatedTime,
lastmod: new Date(market.lastUpdatedTime ?? 0).toISOString(),
})) as ISitemapField[]
return await getServerSideSitemap(ctx, fields)

View File

@ -103,7 +103,7 @@ export function CustomAnalytics(props: Stats) {
title: 'Daily (7d avg)',
content: (
<DailyChart
dailyValues={dailyActiveUsersWeeklyAvg}
dailyValues={dailyActiveUsersWeeklyAvg.map(Math.round)}
startDate={startDate}
/>
),

View File

@ -24,6 +24,7 @@ import { SiteLink } from 'web/components/site-link'
import { Carousel } from 'web/components/carousel'
import { usePagination } from 'web/hooks/use-pagination'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Title } from 'web/components/title'
dayjs.extend(utc)
dayjs.extend(timezone)
@ -48,7 +49,7 @@ const Salem = {
title: 'CSPI/Salem Forecasting Tournament',
blurb: 'Top 5 traders qualify for a UT Austin research fellowship.',
url: 'https://salemcenter.manifold.markets/',
award: '$25,000',
award: 'US$25,000',
endTime: toDate('Jul 31, 2023'),
contractIds: [],
images: [
@ -76,35 +77,36 @@ const Salem = {
}
const tourneys: Tourney[] = [
{
title: 'Fantasy Football Stock Exchange',
blurb: 'How many points will each NFL player score this season?',
award: 'US$2,500',
endTime: toDate('Jan 6, 2023'),
groupId: 'SxGRqXRpV3RAQKudbcNb',
},
{
title: 'Clearer Thinking Regrant Project',
blurb: 'Which projects will Clearer Thinking give a grant to?',
award: '$13,000',
award: 'US$13,000',
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: 'Cause Exploration Prizes',
// blurb:
// 'Which new charity ideas will Open Philanthropy find most promising?',
// award: 'M$100k',
// endTime: toDate('Sep 9, 2022'),
// groupId: 'cMcpBQ2p452jEcJD2SFw',
// },
{
title: 'Fantasy Football Stock Exchange',
blurb: 'How many points will each NFL player score this season?',
award: '$2,500',
endTime: toDate('Jan 6, 2023'),
groupId: 'SxGRqXRpV3RAQKudbcNb',
title: 'Cause Exploration Prizes',
blurb:
'Which new charity ideas will Open Philanthropy find most promising?',
award: 'M$100k',
endTime: toDate('Sep 9, 2022'),
groupId: 'cMcpBQ2p452jEcJD2SFw',
},
{
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',
},
// Tournaments without awards get featured below
@ -158,16 +160,31 @@ export async function getStaticProps() {
export default function TournamentPage(props: { sections: SectionInfo[] }) {
const { sections } = props
const description = `Win real prizes (including cash!) by participating in forecasting
tournaments on current events, sports, science, and more.`
return (
<Page>
<SEO
title="Tournaments"
description="Win money by predicting in forecasting tournaments on current events, sports, science, and more"
/>
<SEO title="Tournaments" description={description} />
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
<Col>
<Title text="🏆 Manifold tournaments" />
<div>{description}</div>
</Col>
<div>
<SectionHeader
url={Salem.url}
title={Salem.title}
award={Salem.award}
endTime={Salem.endTime}
/>
<span className="text-gray-500">{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} />
</div>
{sections.map(
({ tourney, slug, numPeople }) =>
tourney.award && (
tourney.award &&
(tourney.endTime ?? 0) > Date.now() && (
<div key={slug}>
<SectionHeader
url={groupPath(slug, 'about')}
@ -181,17 +198,40 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
</div>
)
)}
<div>
<SectionHeader
url={Salem.url}
title={Salem.title}
award={Salem.award}
endTime={Salem.endTime}
/>
<span className="text-gray-500">{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} />
{/* Title break */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
Past tournaments
</span>
</div>
</div>
{sections.map(
({ tourney, slug, numPeople }) =>
tourney.award &&
(tourney.endTime ?? 0) <= Date.now() && (
<div key={slug}>
<SectionHeader
url={groupPath(slug, 'about')}
title={tourney.title}
ppl={numPeople}
award={tourney.award}
endTime={tourney.endTime}
/>
<span className="text-gray-500">{tourney.blurb}</span>
<MarketCarousel slug={slug} />
</div>
)
)}
{/* Title break */}
<div className="relative">
<div

View File

@ -1,5 +1,6 @@
import { track } from '@amplitude/analytics-browser'
import { Editor } from '@tiptap/core'
import { sum } from 'lodash'
import clsx from 'clsx'
import { PostComment } from 'common/comment'
import { Post } from 'common/post'
@ -109,6 +110,7 @@ export function PostComment(props: {
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
const me = useUser()
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
@ -162,7 +164,11 @@ export function PostComment(props: {
Reply
</button>
)}
<Tipper comment={comment} tips={tips ?? {}} />
<Tipper
comment={comment}
myTip={me ? tips?.[me.id] ?? 0 : 0}
totalTip={sum(Object.values(tips ?? {}))}
/>
</Row>
</div>
</Row>

View File

@ -1,10 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/search</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/add-funds</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/challenges</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/charity</loc><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://manifold.markets/groups</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/tournaments</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/labs</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
</urlset>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 248 204" style="enable-background:new 0 0 248 204;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="Logo_1_">
<path id="white_background" class="st0" d="M221.95,51.29c0.15,2.17,0.15,4.34,0.15,6.53c0,66.73-50.8,143.69-143.69,143.69v-0.04
C50.97,201.51,24.1,193.65,1,178.83c3.99,0.48,8,0.72,12.02,0.73c22.74,0.02,44.83-7.61,62.72-21.66
c-21.61-0.41-40.56-14.5-47.18-35.07c7.57,1.46,15.37,1.16,22.8-0.87C27.8,117.2,10.85,96.5,10.85,72.46c0-0.22,0-0.43,0-0.64
c7.02,3.91,14.88,6.08,22.92,6.32C11.58,63.31,4.74,33.79,18.14,10.71c25.64,31.55,63.47,50.73,104.08,52.76
c-4.07-17.54,1.49-35.92,14.61-48.25c20.34-19.12,52.33-18.14,71.45,2.19c11.31-2.23,22.15-6.38,32.07-12.26
c-3.77,11.69-11.66,21.62-22.2,27.93c10.01-1.18,19.79-3.86,29-7.95C240.37,35.29,231.83,44.14,221.95,51.29z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -18,6 +18,7 @@ module.exports = {
colors: {
'red-25': '#FDF7F6',
'greyscale-1': '#FBFBFF',
'greyscale-1.5': '#F4F4FB',
'greyscale-2': '#E7E7F4',
'greyscale-3': '#D8D8EB',
'greyscale-4': '#B1B1C7',

174
yarn.lock
View File

@ -1310,7 +1310,14 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.18.9":
version "7.19.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.2":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
@ -2351,6 +2358,19 @@
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.1.tgz#d822792e589aac005462491dd62f86095e0c3bef"
integrity sha512-gMd6uIs1U4Oz718Z5gFoV0o/vD43/4zvbyiJN9Dt7PK9Ubxn+TmJwTmYwyNJc5KxxU1t0CmgTNgwZX9+4NjCnQ==
"@hello-pangea/dnd@16.0.0":
version "16.0.0"
resolved "https://registry.yarnpkg.com/@hello-pangea/dnd/-/dnd-16.0.0.tgz#b97791286395924ffbdb4cd0f27f06f2985766d5"
integrity sha512-FprEzwrGMvyclVf8pWTrPbUV7/ZFt6NmL76ePj1mMyZG195htDUkmvET6CBwKJTXmV+AE/GyK4Lv3wpCqrlY/g==
dependencies:
"@babel/runtime" "^7.18.9"
css-box-model "^1.2.1"
memoize-one "^6.0.0"
raf-schd "^4.0.3"
react-redux "^8.0.2"
redux "^4.2.0"
use-memo-one "^1.1.2"
"@heroicons/react@1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324"
@ -3434,7 +3454,7 @@
resolved "https://registry.yarnpkg.com/@types/hogan.js/-/hogan.js-3.0.1.tgz#64c54407b30da359763e14877f5702b8ae85d61c"
integrity sha512-D03i/2OY7kGyMq9wdQ7oD8roE49z/ZCZThe/nbahtvuqCNZY9T2MfedOWyeBdbEpY2W8Gnh/dyJLdFtUCOkYbg==
"@types/hoist-non-react-statics@^3.3.0":
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
@ -3567,30 +3587,13 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-beautiful-dnd@13.1.2":
version "13.1.2"
resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz#510405abb09f493afdfd898bf83995dc6385c130"
integrity sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==
"@types/react-dom@18.0.6":
version "18.0.6"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1"
integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==
dependencies:
"@types/react" "*"
"@types/react-dom@17.0.2":
version "17.0.2"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.2.tgz#35654cf6c49ae162d5bc90843d5437dc38008d43"
integrity sha512-Icd9KEgdnFfJs39KyRyr0jQ7EKhq8U6CcHRMGAS45fp5qgUvxL3ujUCfWFttUK2UErqZNj97t9gsVPNAqcwoCg==
dependencies:
"@types/react" "*"
"@types/react-redux@^7.1.20":
version "7.1.24"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0"
integrity sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==
dependencies:
"@types/hoist-non-react-statics" "^3.3.0"
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react-router-config@*":
version "5.0.6"
resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.6.tgz#87c5c57e72d241db900d9734512c50ccec062451"
@ -3617,7 +3620,7 @@
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react@*", "@types/react@17.0.43", "@types/react@^17.0.2":
"@types/react@*", "@types/react@^17.0.2":
version "17.0.43"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55"
integrity sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==
@ -3626,6 +3629,15 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@18.0.21":
version "18.0.21"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.21.tgz#b8209e9626bb00a34c76f55482697edd2b43cc67"
integrity sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/retry@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@ -3675,6 +3687,11 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/ws@^8.5.1":
version "8.5.3"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
@ -5202,7 +5219,7 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
css-box-model@^1.2.0:
css-box-model@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
@ -8683,10 +8700,10 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "1.0.3"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
memoize-one@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
merge-descriptors@1.0.1:
version "1.0.1"
@ -10413,7 +10430,7 @@ quick-lru@^5.1.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
raf-schd@^4.0.2:
raf-schd@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
@ -10465,19 +10482,6 @@ react-base16-styling@^0.6.0:
lodash.flow "^3.3.0"
pure-color "^1.2.0"
react-beautiful-dnd@13.1.1:
version "13.1.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==
dependencies:
"@babel/runtime" "^7.9.2"
css-box-model "^1.2.0"
memoize-one "^5.1.1"
raf-schd "^4.0.2"
react-redux "^7.2.0"
redux "^4.0.4"
use-memo-one "^1.1.1"
react-confetti@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.0.1.tgz#d4f57b5a021dd908a6243b8f63b6009b00818d10"
@ -10515,7 +10519,15 @@ react-dev-utils@^12.0.0:
strip-ansi "^6.0.1"
text-table "^0.2.0"
react-dom@17.0.2, react-dom@^17.0.1:
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-dom@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@ -10529,14 +10541,14 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-expanding-textarea@2.3.5:
version "2.3.5"
resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.5.tgz#310c28ab242c724e042589ac3ea400dd68ad1488"
integrity sha512-mPdtg3CxSgZFcsRLf80jueBWy1Zlh9AKy76S7dGXUKinvo4EypMavZa2iC0hEnLxY0tcwWR+n/K8B21TkttpUw==
react-expanding-textarea@2.3.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.6.tgz#daa50e5110dd71ca79e1df8e056b0b2eb0e8a84a"
integrity sha512-LjkyZv1LilMlt+6/yYn9F1FlcK8iQU96myeq6PwU/a7IaQMkSwndSB1SAhMKgxSrUR71VbqsqnAHQ741WbAU/Q==
dependencies:
fast-shallow-equal "^1.0.0"
react-with-forwarded-ref "^0.3.3"
tslib "^2.0.3"
react-with-forwarded-ref "^0.3.5"
tslib "^2.4.0"
react-fast-compare@^3.2.0:
version "3.2.0"
@ -10585,10 +10597,10 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-is@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-json-view@^1.21.3:
version "1.21.3"
@ -10626,17 +10638,17 @@ react-query@3.39.0:
broadcast-channel "^3.4.1"
match-sorter "^6.0.2"
react-redux@^7.2.0:
version "7.2.8"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de"
integrity sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==
react-redux@^8.0.2:
version "8.0.4"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.4.tgz#80c31dffa8af9526967c4267022ae1525ff0e36a"
integrity sha512-yMfQ7mX6bWuicz2fids6cR1YT59VTuT8MKyyE310wJQlINKENCeT1UcPdEiX6znI5tF8zXyJ/VYvDgeGuaaNwQ==
dependencies:
"@babel/runtime" "^7.15.4"
"@types/react-redux" "^7.1.20"
"@babel/runtime" "^7.12.1"
"@types/hoist-non-react-statics" "^3.3.1"
"@types/use-sync-external-store" "^0.0.3"
hoist-non-react-statics "^3.3.2"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^17.0.2"
react-is "^18.0.0"
use-sync-external-store "^1.0.0"
react-router-config@^5.1.1:
version "5.1.1"
@ -10690,14 +10702,21 @@ react-twitter-embed@4.0.4:
dependencies:
scriptjs "^2.5.9"
react-with-forwarded-ref@^0.3.3:
version "0.3.4"
resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5"
integrity sha512-SRq/uTdTh+02JDwYzEEhY2aNNWl/CP2EKP2nQtXzhJw06w6PgYnJt2ObrebvFJu6+wGzX3vDHU3H/ux9hxyZUQ==
react-with-forwarded-ref@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.5.tgz#7d0bae2a9996fc91493f40ab179b8c54d29cfab9"
integrity sha512-BJK4q0Nvqg4AFwc+LV+PZZb2nxS1ZqQlS9hY14TdIyg7Lapzirk/V/TtbYjPFSsm/fGm0wC4tsgI1IDhxSVVSQ==
dependencies:
tslib "^2.0.3"
react@17.0.2, react@^17.0.1:
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
react@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
@ -10789,7 +10808,7 @@ recursive-readdir@^2.2.2:
dependencies:
minimatch "3.0.4"
redux@^4.0.0, redux@^4.0.4:
redux@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==
@ -11169,6 +11188,13 @@ scheduler@^0.20.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
schema-utils@2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7"
@ -12394,12 +12420,12 @@ use-latest@^1.2.1:
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-memo-one@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20"
integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==
use-memo-one@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==
use-sync-external-store@1.2.0:
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==