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 profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100 const profitPercent = (profit / totalInvested) * 100
@ -221,8 +220,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return { return {
invested, invested,
loan,
payout, payout,
netPayout,
profit, profit,
profitPercent, profitPercent,
totalShares, totalShares,
@ -233,8 +232,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() { export function getContractBetNullMetrics() {
return { return {
invested: 0, invested: 0,
loan: 0,
payout: 0, payout: 0,
netPayout: 0,
profit: 0, profit: 0,
profitPercent: 0, profitPercent: 0,
totalShares: {} as { [outcome: string]: number }, 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.`, If you would like to support our work, you can do so by getting involved or by donating.`,
}, },
{ {
name: 'CaRLA', name: 'CaRLA',
website: 'https://carlaef.org/', website: 'https://carlaef.org/',
photo: 'https://i.imgur.com/IsNVTOY.png', 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.`, 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) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = contract const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(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 ( return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core'
export type Post = { export type Post = {
id: string id: string
title: string title: string
subtitle: string
content: JSONContent content: JSONContent
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
@ -17,3 +18,4 @@ export type DateDoc = Post & {
} }
export const MAX_POST_TITLE_LENGTH = 480 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) => { dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) { if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]' 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) return generateText(newText, exhibitExts)

View File

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

View File

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

View File

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

View File

@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import {
getAllPrivateUsers, getAllPrivateUsers,
getGroup,
getPrivateUser, getPrivateUser,
getUser, getUser,
getValues, getValues,
isProd, isProd,
log, log,
} from './utils' } from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random' 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 { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00) // every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1') .pubsub.schedule('* 19-20 * * 1')
.timeZone('Etc/UTC') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
@ -40,20 +43,30 @@ export async function getTrendingContracts() {
) )
} }
async function sendTrendingMarketsEmailsToAllUsers() { export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
const privateUsers = isProd() const privateUsers = isProd()
? await getAllPrivateUsers() ? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) : filterDefined([
// get all users that haven't unsubscribed from weekly emails await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers const privateUsersToSendEmailsTo = privateUsers
.filter((user) => { // Get all users that haven't unsubscribed from weekly emails
return ( .filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') && user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent !user.weeklyTrendingEmailSent
) )
}) .slice(0, 90) // Send the emails out in batches
.slice(150) // 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( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 50)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
// TODO: convert to Promise.all const uniqueTrendingContracts = removeSimilarQuestions(
for (const privateUser of privateUsersToSendEmailsTo) { trendingContracts,
if (!privateUser.email) { trendingContracts,
log(`No email for ${privateUser.username}`) true
continue ).slice(0, 20)
}
const contractsAvailableToSend = trendingContracts.filter((contract) => { await Promise.all(
return !contract.uniqueBettorIds?.includes(privateUser.id) privateUsersToSendEmailsTo.map(async (privateUser) => {
}) if (!privateUser.email) {
if (contractsAvailableToSend.length < numContractsToSend) { log(`No email for ${privateUser.username}`)
log('not enough new, unbet-on contracts to send to user', privateUser.id) return
await firestore.collection('private-users').doc(privateUser.id).update({ }
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, 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 const fiveMinutes = 5 * 60 * 1000
@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng) shuffle(contracts, rng)
return contracts.slice(0, count) 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, numericValue,
resolution, resolution,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary' let resolutionColor = 'text-primary'
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8"> <meta charset="utf-8">
<title>Generated Image</title> <title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> </head>
<style> <style>
${getTemplateCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div> </div>
<div class="flex flex-row justify-between gap-12 pt-36"> <div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight"> <div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
${truncatedQuestion} ${question}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
${ ${
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata --> <!-- Metadata -->
<div class="absolute bottom-16"> <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} ${metadata}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' 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 { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'

View File

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

View File

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

View File

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

View File

@ -10,16 +10,54 @@ export type ColorType =
| 'indigo' | 'indigo'
| 'yellow' | 'yellow'
| 'gray' | 'gray'
| 'gray-outline'
| 'gradient' | 'gradient'
| 'gray-white' | 'gray-white'
| 'highlight-blue' | '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: { export function Button(props: {
className?: string className?: string
onClick?: MouseEventHandler<any> | undefined onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean
@ -35,42 +73,10 @@ export function Button(props: {
loading, loading,
} = props } = 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 ( return (
<button <button
type={type} type={type}
className={clsx( className={clsx(buttonClass(size, color), className)}
'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
)}
disabled={disabled || loading} disabled={disabled || loading}
onClick={onClick} 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 clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' import { SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -16,7 +15,6 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label' import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input' import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
@ -26,6 +24,7 @@ import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor' import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -302,16 +301,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" /> <Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div> <div>Share the challenge using the link.</div>
<button <CopyLinkButton url={challengeSlug} />
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>
<QRCode url={challengeSlug} className="self-center" /> <QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}> <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 { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props const { charity } = props
@ -15,43 +17,44 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount) const raised = sumBy(txns, (txn) => txn.amount)
return ( return (
<Link href={`/charity/${slug}`} passHref> <Link href={`/charity/${slug}`}>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> <a className="flex-1">
<Row className="mt-6 mb-2"> <Card className="!rounded-2xl">
{tags?.includes('Featured') && <FeaturedBadge />} <Row className="mt-6 mb-2">
</Row> {tags?.includes('Featured') && <FeaturedBadge />}
<div className="px-8"> </Row>
<figure className="relative h-32"> <div className="px-8">
{photo ? ( <figure className="relative h-32">
<Image src={photo} alt="" layout="fill" objectFit="contain" /> {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" /> ) : (
)} <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
</figure> )}
</div> </figure>
<div className="card-body"> </div>
{/* <h3 className="card-title line-clamp-3">{name}</h3> */} <Col className="p-8">
<div className="line-clamp-4 text-sm">{preview}</div> <div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && ( {raised > 0 && (
<> <>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900"> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1"> <Row className="items-baseline gap-1">
<span className="text-3xl font-semibold"> <span className="text-3xl font-semibold">
{formatUsd(raised)} {formatUsd(raised)}
</span> </span>
raised raised
</Row> </Row>
{/* {match && ( {/* {match && (
<Col className="text-gray-500"> <Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span> <span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span> <span className="">match</span>
</Col> </Col>
)} */} )} */}
</Row> </Row>
</> </>
)} )}
</div> </Col>
</div> </Card>
</a>
</Link> </Link>
) )
} }

View File

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

View File

@ -26,11 +26,11 @@ const getNumericChartData = (contract: NumericContract) => {
const NumericChartTooltip = ( const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint> props: TooltipProps<number, DistributionPoint>
) => { ) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const x = xScale.invert(mouseX) const amount = xScale.invert(x)
return ( 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> <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 = ( const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>> props: TooltipProps<Date, HistoryPoint<Bet>>
) => { ) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}

View File

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

View File

@ -1,12 +1,4 @@
import { import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection' import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis' import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush' import { brushX, D3BrushEvent } from 'd3-brush'
@ -17,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' 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 } 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: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
@ -130,8 +145,10 @@ export const SVGChart = <X, TT>(props: {
margin: Margin margin: Margin
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
ttParams: TooltipParams<TT> | undefined
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined onMouseOver?: (mouseX: number, mouseY: number) => void
onMouseLeave?: () => void
Tooltip?: TooltipComponent<X, TT> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { const {
@ -141,16 +158,18 @@ export const SVGChart = <X, TT>(props: {
margin, margin,
xAxis, xAxis,
yAxis, yAxis,
onMouseOver, ttParams,
onSelect, onSelect,
onMouseOver,
onMouseLeave,
Tooltip, Tooltip,
} = props } = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize() const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - (margin.left + margin.right) const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom) const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), []) const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false) const justSelected = useRef(false)
useEffect(() => { useEffect(() => {
@ -165,7 +184,7 @@ export const SVGChart = <X, TT>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouse(undefined) onMouseLeave?.()
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -181,44 +200,49 @@ export const SVGChart = <X, TT>(props: {
.select('.selection') .select('.selection')
.attr('shape-rendering', 'null') .attr('shape-rendering', 'null')
} }
}, [innerW, innerH, onSelect]) }, [innerW, innerH, onSelect, onMouseLeave])
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev) const [x, y] = pointer(ev)
const data = onMouseOver(x, y) onMouseOver(x, y)
if (data !== undefined) { }
setMouse({ x, y, data }) }
} else {
setMouse(undefined) 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 = () => { const onPointerLeave = () => {
setMouse(undefined) onMouseLeave?.()
} }
return ( return (
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{mouse && Tooltip && ( {ttParams && Tooltip && (
<TooltipContainer <TooltipContainer
setElem={tooltipMeasure.setElem} setElem={tooltipMeasure.setElem}
margin={margin} margin={margin}
pos={getTooltipPosition( pos={getTooltipPosition(
mouse.x, ttParams.x,
mouse.y, ttParams.y,
innerW, innerW,
innerH, innerH,
tooltipMeasure.width, tooltipMeasure.width ?? 140,
tooltipMeasure.height tooltipMeasure.height ?? 35,
isMobile ?? false
)} )}
> >
<Tooltip <Tooltip
xScale={xAxis.scale()} xScale={xAxis.scale()}
mouseX={mouse.x} x={ttParams.x}
mouseY={mouse.y} y={ttParams.y}
data={mouse.data} data={ttParams.data}
/> />
</TooltipContainer> </TooltipContainer>
)} )}
@ -230,18 +254,30 @@ export const SVGChart = <X, TT>(props: {
<XAxis axis={xAxis} w={innerW} h={innerH} /> <XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g> <g clipPath={`url(#${clipPathId})`}>{children}</g>
<g {!isMobile ? (
ref={overlayRef} <g
x="0" ref={overlayRef}
y="0" x="0"
width={innerW} y="0"
height={innerH} width={innerW}
fill="none" height={innerH}
pointerEvents="all" fill="none"
onPointerEnter={onPointerMove} pointerEvents="all"
onPointerMove={onPointerMove} onPointerEnter={onPointerMove}
onPointerLeave={onPointerLeave} onPointerMove={onPointerMove}
/> onPointerLeave={onPointerLeave}
/>
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g> </g>
</svg> </svg>
</div> </div>
@ -255,31 +291,34 @@ export const getTooltipPosition = (
mouseY: number, mouseY: number,
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
tooltipWidth?: number, tooltipWidth: number,
tooltipHeight?: number tooltipHeight: number,
isMobile: boolean
) => { ) => {
let left = mouseX + 12 let left = mouseX + 12
let bottom = containerHeight - mouseY + 12 let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) { if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) { if (overflow > 0) {
left -= overflow left -= overflow
} }
} }
if (tooltipHeight != null) { if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY const overflow = tooltipHeight - mouseY
if (overflow > 0) { if (overflow > 0) {
bottom -= overflow bottom -= overflow
} }
} }
return { left, bottom } return { left, bottom }
} }
export type TooltipProps<X, T> = { export type TooltipParams<T> = { x: number; y: number; data: T }
mouseX: number export type TooltipProps<X, T> = TooltipParams<T> & {
mouseY: number
xScale: ContinuousScale<X> xScale: ContinuousScale<X>
data: T
} }
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, 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 DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span> <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 DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span> <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" /> <LoadingIndicator spinnerClassName="border-gray-500" />
)} )}
</TextEditor> </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?: { cancelBtn?: {
label?: string label?: string
className?: string color?: ColorType
} }
submitBtn?: { submitBtn?: {
label?: string label?: string
className?: string color?: ColorType
isSubmitting?: boolean isSubmitting?: boolean
} }
children: ReactNode children: ReactNode
@ -53,14 +53,14 @@ export function ConfirmationButton(props: {
<Col className="gap-4 rounded-md bg-white px-8 py-6"> <Col className="gap-4 rounded-md bg-white px-8 py-6">
{children} {children}
<Row className="gap-4"> <Row className="gap-4">
<div <Button
className={clsx('btn', cancelBtn?.className)} color={cancelBtn?.color ?? 'gray-white'}
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
{cancelBtn?.label ?? 'Cancel'} {cancelBtn?.label ?? 'Cancel'}
</div> </Button>
<Button <Button
className={clsx('btn', submitBtn?.className)} color={submitBtn?.color ?? 'blue'}
onClick={ onClick={
onSubmitWithSuccess onSubmitWithSuccess
? () => ? () =>
@ -100,18 +100,11 @@ export function ResolveConfirmationButton(props: {
onResolve: () => void onResolve: () => void
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClass?: string
color?: ColorType color?: ColorType
disabled?: boolean disabled?: boolean
}) { }) {
const { const { onResolve, isSubmitting, openModalButtonClass, color, disabled } =
onResolve, props
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
@ -126,7 +119,7 @@ export function ResolveConfirmationButton(props: {
}} }}
submitBtn={{ submitBtn={{
label: 'Resolve', label: 'Resolve',
className: clsx('border-none', submitButtonClass), color: color,
isSubmitting, isSubmitting,
}} }}
onSubmit={onResolve} onSubmit={onResolve}

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants' import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore' import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle' import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../duplicate-contract-button'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { BETTORS, User } from 'common/user' import { BETTORS, User } from 'common/user'
import { Button } from '../button' 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={{ openModalBtn={{
label: '', label: '',
icon: <FlagIcon className="h-5 w-5" />, icon: <FlagIcon className="h-5 w-5" />,
className: clsx(flagClass, reporting && 'btn-disabled loading'), disabled: reporting,
}} className: clsx(flagClass),
cancelBtn={{
label: 'Cancel',
className: 'border-none btn-sm btn-ghost self-center',
}}
submitBtn={{
label: 'Submit',
className: 'btn-secondary',
}} }}
onSubmitWithSuccess={onSubmit} onSubmitWithSuccess={onSubmit}
disabled={userReported} disabled={userReported}

View File

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

View File

@ -1,13 +1,10 @@
import { sortBy } from 'lodash'
import clsx from 'clsx' import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract' import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format' import { formatPercent } from 'common/util/format'
import { SiteLink } from '../site-link' import { sortBy } from 'lodash'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractCardProbChange } from './contract-card'
export function ProbChangeTable(props: { export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined changes: CPMMContract[] | undefined
@ -39,46 +36,21 @@ export function ProbChangeTable(props: {
if (rows === 0) return <div className="px-4 text-gray-500">None</div> if (rows === 0) return <div className="px-4 text-gray-500">None</div>
return ( 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="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1 divide-y"> <Col className="flex-1 gap-4">
{filteredPositiveChanges.map((contract) => ( {filteredPositiveChanges.map((contract) => (
<ProbChangeRow key={contract.id} contract={contract} /> <ContractCardProbChange key={contract.id} contract={contract} />
))} ))}
</Col> </Col>
<Col className="flex-1 divide-y"> <Col className="flex-1 gap-4">
{filteredNegativeChanges.map((contract) => ( {filteredNegativeChanges.map((contract) => (
<ProbChangeRow key={contract.id} contract={contract} /> <ContractCardProbChange key={contract.id} contract={contract} />
))} ))}
</Col> </Col>
</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: { export function ProbChange(props: {
contract: CPMMContract contract: CPMMContract
className?: string 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 { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -9,9 +6,7 @@ import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button' import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title' import { Title } from '../title'
import { TweetButton } from '../tweet-button' import { TweetButton } from '../tweet-button'
import { Button } from '../button' import { withTracking } from 'web/lib/service/analytics'
import { copyToClipboard } from 'web/lib/util/copy'
import { track, withTracking } from 'web/lib/service/analytics'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { User } from 'common/user' import { User } from 'common/user'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
@ -22,6 +17,8 @@ import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon' import ChallengeIcon from 'web/lib/icons/challenge-icon'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { CopyLinkButton } from '../copy-link-button'
import { Button } from '../button'
export function ShareModal(props: { export function ShareModal(props: {
contract: Contract contract: Contract
@ -34,7 +31,6 @@ export function ShareModal(props: {
const [openCreateChallengeModal, setOpenCreateChallengeModal] = const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false) useState(false)
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const showChallenge = const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
@ -61,40 +57,24 @@ export function ShareModal(props: {
width={150} width={150}
height={150} height={150}
/> />
<Button <CopyLinkButton url={shareUrl} tracking="copy share link" />
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>
<Row className="z-0 flex-wrap justify-center gap-4 self-center"> <Row className="z-0 flex-wrap justify-center gap-4 self-center">
<TweetButton <TweetButton tweetText={getTweetText(contract, shareUrl)} />
className="self-start"
tweetText={getTweetText(contract, shareUrl)}
/>
<ShareEmbedButton contract={contract} /> <ShareEmbedButton contract={contract} />
{showChallenge && ( {showChallenge && (
<button <Button
className={ size="2xs"
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500' color="override"
} className="gap-1 border-2 border-indigo-500 text-indigo-500 hover:bg-indigo-500 hover:text-white"
onClick={withTracking( onClick={withTracking(
() => setOpenCreateChallengeModal(true), () => setOpenCreateChallengeModal(true),
'click challenge button' 'click challenge button'
)} )}
> >
<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge <ChallengeIcon className="h-4 w-4" /> Challenge
<CreateChallengeModal <CreateChallengeModal
isOpen={openCreateChallengeModal} isOpen={openCreateChallengeModal}
setOpen={(open) => { setOpen={(open) => {
@ -106,7 +86,7 @@ export function ShareModal(props: {
user={user} user={user}
contract={contract} contract={contract}
/> />
</button> </Button>
)} )}
</Row> </Row>
</Col> </Col>

View File

@ -21,10 +21,11 @@ export function CopyLinkButton(props: {
return ( return (
<Row className="w-full"> <Row className="w-full">
<input <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 readOnly
type="text" type="text"
value={displayUrl ?? url} value={displayUrl ?? url}
onFocus={(e) => e.target.select()}
/> />
<Menu <Menu
@ -37,7 +38,7 @@ export function CopyLinkButton(props: {
> >
<Menu.Button <Menu.Button
className={clsx( 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 buttonClassName
)} )}
> >

View File

@ -13,6 +13,8 @@ import { Group } from 'common/group'
export function CreatePost(props: { group?: Group }) { export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [subtitle, setSubtitle] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -22,12 +24,17 @@ export function CreatePost(props: { group?: Group }) {
disabled: isSubmitting, 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) { async function savePost(title: string) {
if (!editor) return if (!editor) return
const newPost = { const newPost = {
title: title, title: title,
subtitle: subtitle,
content: editor.getJSON(), content: editor.getJSON(),
groupId: group?.id, groupId: group?.id,
} }
@ -62,6 +69,20 @@ export function CreatePost(props: { group?: Group }) {
onChange={(e) => setTitle(e.target.value || '')} onChange={(e) => setTitle(e.target.value || '')}
/> />
<Spacer h={6} /> <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"> <label className="label">
<span className="mb-1"> <span className="mb-1">
Content<span className={'text-red-700'}> *</span> Content<span className={'text-red-700'}> *</span>

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import {
} from '@tiptap/react' } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract' import { useContract } from 'web/hooks/use-contract'
import { ContractCard } from '../contract/contract-card' import { ContractMention } from '../contract/contract-mention'
const name = 'contract-mention-component' const name = 'contract-mention-component'
@ -14,13 +14,8 @@ const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id) const contract = useContract(props.node.attrs.id)
return ( return (
<NodeViewWrapper className={clsx(name, 'not-prose')}> <NodeViewWrapper className={clsx(name, 'not-prose inline')}>
{contract && ( {contract && <ContractMention contract={contract} />}
<ContractCard
contract={contract}
className="my-2 w-full border border-gray-100"
/>
)}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@ -37,6 +32,5 @@ export const DisplayContractMention = Mention.extend({
addNodeView: () => addNodeView: () =>
ReactNodeViewRenderer(ContractMentionComponent, { ReactNodeViewRenderer(ContractMentionComponent, {
// On desktop, render cards below half-width so you can stack two // 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 { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { sum } from 'lodash'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -14,6 +15,8 @@ import {
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' 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 { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
@ -27,11 +30,17 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyTo, setReplyTo] = useState<ReplyTo>() const [replyTo, setReplyTo] = useState<ReplyTo>()
const user = useUser()
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const highlighted = router.asPath.endsWith(`#${answerElementId}`) const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const answerRef = useRef<HTMLDivElement>(null) const answerRef = useRef<HTMLDivElement>(null)
const onSubmitComment = useEvent(() => setReplyTo(undefined))
const onReplyClick = useEvent((comment: ContractComment) => {
setReplyTo({ id: comment.id, username: comment.userUsername })
})
useEffect(() => { useEffect(() => {
if (highlighted && answerRef.current != null) { if (highlighted && answerRef.current != null) {
answerRef.current.scrollIntoView(true) answerRef.current.scrollIntoView(true)
@ -95,10 +104,10 @@ export function FeedAnswerCommentGroup(props: {
indent={true} indent={true}
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id] ?? {}} myTip={user ? tips[comment.id]?.[user.id] : undefined}
onReplyClick={() => totalTip={sum(Object.values(tips[comment.id] ?? {}))}
setReplyTo({ id: comment.id, username: comment.userUsername }) showTip={true}
} onReplyClick={onReplyClick}
/> />
))} ))}
</Col> </Col>
@ -112,7 +121,7 @@ export function FeedAnswerCommentGroup(props: {
contract={contract} contract={contract}
parentAnswerOutcome={answer.number.toString()} parentAnswerOutcome={answer.number.toString()}
replyTo={replyTo} replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)} onSubmitComment={onSubmitComment}
/> />
</div> </div>
)} )}

View File

@ -1,3 +1,4 @@
import React, { memo, useEffect } from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -8,7 +9,6 @@ import clsx from 'clsx'
import { formatMoney, formatPercent } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { useEffect } from 'react'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' 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 { UserLink } from 'web/components/user-link'
import { BETTOR } from 'common/user' 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 { contract, bet } = props
const { userAvatarUrl, userUsername, createdTime } = bet const { userAvatarUrl, userUsername, createdTime } = bet
const showUser = dayjs(createdTime).isAfter('2022-06-01') const showUser = dayjs(createdTime).isAfter('2022-06-01')
@ -36,7 +39,7 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
/> />
</Row> </Row>
) )
} })
export function BetStatusText(props: { export function BetStatusText(props: {
contract: Contract 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 { ContractComment } from 'common/comment'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useRef, useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' 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 { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper' 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 { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button' import { AwardBountyButton } from 'web/components/award-bounty-button'
@ -32,6 +35,12 @@ export function FeedCommentThread(props: {
const { contract, threadComments, tips, parentComment } = props const { contract, threadComments, tips, parentComment } = props
const [replyTo, setReplyTo] = useState<ReplyTo>() 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 ( return (
<Col className="relative w-full items-stretch gap-3 pb-4"> <Col className="relative w-full items-stretch gap-3 pb-4">
<span <span
@ -44,10 +53,10 @@ export function FeedCommentThread(props: {
indent={commentIdx != 0} indent={commentIdx != 0}
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id] ?? {}} myTip={user ? tips[comment.id]?.[user.id] : undefined}
onReplyClick={() => totalTip={sum(Object.values(tips[comment.id] ?? {}))}
setReplyTo({ id: comment.id, username: comment.userUsername }) showTip={true}
} onReplyClick={onReplyClick}
/> />
))} ))}
{replyTo && ( {replyTo && (
@ -60,7 +69,7 @@ export function FeedCommentThread(props: {
contract={contract} contract={contract}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyTo={replyTo} replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)} onSubmitComment={onSubmitComment}
/> />
</Col> </Col>
)} )}
@ -68,14 +77,17 @@ export function FeedCommentThread(props: {
) )
} }
export function FeedComment(props: { export const FeedComment = memo(function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
tips?: CommentTips showTip?: boolean
myTip?: number
totalTip?: number
indent?: boolean 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 { const {
text, text,
content, content,
@ -180,12 +192,18 @@ export function FeedComment(props: {
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
onClick={onReplyClick} onClick={() => onReplyClick(comment)}
> >
Reply Reply
</button> </button>
)} )}
{tips && <Tipper comment={comment} tips={tips} />} {showTip && (
<Tipper
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)}
{(contract.openCommentBounties ?? 0) > 0 && ( {(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} /> <AwardBountyButton comment={comment} contract={contract} />
)} )}
@ -193,7 +211,7 @@ export function FeedComment(props: {
</div> </div>
</Row> </Row>
) )
} })
function CommentStatus(props: { function CommentStatus(props: {
contract: Contract contract: Contract

View File

@ -53,30 +53,30 @@ export function ContractGroupsList(props: {
/> />
</Col> </Col>
)} )}
{groups.length === 0 && ( <Col className="h-96 overflow-auto">
<Col className="ml-2 h-full justify-center text-gray-500"> {groups.length === 0 && (
No groups yet... <Col className="text-greyscale-4">No groups yet...</Col>
</Col> )}
)} {groups.map((group) => (
{groups.map((group) => ( <Row
<Row key={group.id}
key={group.id} className={clsx('items-center justify-between gap-2 p-2')}
className={clsx('items-center justify-between gap-2 p-2')} >
> <Row className="line-clamp-1 items-center gap-2">
<Row className="line-clamp-1 items-center gap-2"> <GroupLinkItem group={group} />
<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> </Row>
{user && canModifyGroupContracts(group, user.id) && ( ))}
<Button </Col>
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 { useRouter } from 'next/router'
import { useState } from 'react' import { useState } from 'react'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
@ -87,10 +86,8 @@ export function CreateGroupButton(props: {
}} }}
submitBtn={{ submitBtn={{
label: 'Create', label: 'Create',
className: clsx( color: 'green',
'normal-case', isSubmitting,
isSubmitting ? 'loading btn-disabled' : ' btn-primary'
),
}} }}
onSubmitWithSuccess={onSubmit} onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => { onOpenChanged={(isOpen) => {

View File

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

View File

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

View File

@ -36,8 +36,11 @@ import { CopyLinkButton } from '../copy-link-button'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { PostCard } from '../post-card' import { PostCard, PostCardList } from '../post-card'
import { LoadingIndicator } from '../loading-indicator' 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 const MAX_TRENDING_POSTS = 6
@ -59,7 +62,6 @@ export function GroupOverview(props: {
posts={posts} posts={posts}
isEditable={isEditable} isEditable={isEditable}
/> />
{(group.aboutPostId != null || isEditable) && ( {(group.aboutPostId != null || isEditable) && (
<> <>
<SectionHeader label={'About'} href={'/post/' + group.slug} /> <SectionHeader label={'About'} href={'/post/' + group.slug} />
@ -87,10 +89,55 @@ export function GroupOverview(props: {
user={user} user={user}
memberIds={memberIds} memberIds={memberIds}
/> />
<GroupPosts group={group} posts={posts} />
</Col> </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: { function GroupOverviewPinned(props: {
group: Group group: Group
posts: Post[] posts: Post[]

View File

@ -163,7 +163,7 @@ export function GroupSelector(props: {
user={creator} user={creator}
onOpenStateChange={setIsCreatingNewGroup} onOpenStateChange={setIsCreatingNewGroup}
className={ 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'} label={'Create a new Group'}
addGroupIdParamOnSubmit addGroupIdParamOnSubmit

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { DocumentIcon } from '@heroicons/react/solid'
import clsx from 'clsx' import clsx from 'clsx'
import { Post } from 'common/post' import { Post } from 'common/post'
import Link from 'next/link' import Link from 'next/link'
@ -6,8 +7,8 @@ import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts' import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Card } from './card'
import { CardHighlightOptions } from './contract/contracts-grid' import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link' import { UserLink } from './user-link'
export function PostCard(props: { export function PostCard(props: {
@ -25,9 +26,9 @@ export function PostCard(props: {
return ( return (
<div className="relative py-1"> <div className="relative py-1">
<Row <Card
className={clsx( 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 itemIds?.includes(post.id) && highlightClassName
)} )}
> >
@ -44,9 +45,20 @@ export function PostCard(props: {
<span className="mx-1"></span> <span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span> <span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div> </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> </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 ? ( {onPostClick ? (
<a <a
className="absolute top-0 left-0 right-0 bottom-0" className="absolute top-0 left-0 right-0 bottom-0"
@ -80,3 +92,23 @@ export function PostCard(props: {
</div> </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 { DateTimeTooltip } from './datetime-tooltip'
import React from 'react' import React, { useEffect, useState } from 'react'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
export function RelativeTimestamp(props: { time: number }) { export function RelativeTimestamp(props: { time: number }) {
const { time } = props const { time } = props
const [isClient, setIsClient] = useState(false)
useEffect(() => {
// Only render on client to prevent difference from server.
setIsClient(true)
}, [])
return ( return (
<DateTimeTooltip <DateTimeTooltip
className="ml-1 whitespace-nowrap text-gray-400" className="ml-1 whitespace-nowrap text-gray-400"
time={time} time={time}
> >
{fromNow(time)} {isClient ? fromNow(time) : ''}
</DateTimeTooltip> </DateTimeTooltip>
) )
} }

View File

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

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 { Modal } from './layout/modal'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Title } from './title' import { Title } from './title'
import { Button } from './button'
import { TweetButton } from './tweet-button' import { TweetButton } from './tweet-button'
import { Row } from './layout/row' import { CopyLinkButton } from './copy-link-button'
export function SharePostModal(props: { export function SharePostModal(props: {
shareUrl: string shareUrl: string
@ -16,30 +11,14 @@ export function SharePostModal(props: {
}) { }) {
const { isOpen, setOpen, shareUrl } = props const { isOpen, setOpen, shareUrl } = props
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
return ( return (
<Modal open={isOpen} setOpen={setOpen} size="md"> <Modal open={isOpen} setOpen={setOpen} size="md">
<Col className="gap-4 rounded bg-white p-4"> <Col className="gap-4 rounded bg-white p-4">
<Title className="!mt-0 !mb-2" text="Share this post" /> <Title className="!mt-0 !mb-2" text="Share this post" />
<Button <CopyLinkButton url={shareUrl} tracking="copy share post link" />
size="2xl" <div className="self-center">
color="gradient" <TweetButton tweetText={shareUrl} />
className={'mb-2 flex max-w-xs self-center'} </div>
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>
</Col> </Col>
</Modal> </Modal>
) )

View File

@ -29,7 +29,14 @@ export const SizedContainer = (props: {
}, [threshold, fullHeight, mobileHeight]) }, [threshold, fullHeight, mobileHeight])
return ( return (
<div ref={containerRef}> <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> </div>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -244,7 +244,7 @@ function Button(props: {
type="button" type="button"
className={clsx( className={clsx(
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm', '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 === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-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() const q = new QueryClient()
export const getCachedContracts = async () => export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), { q.fetchQuery(['contracts'], () => listAllContracts(10000), {
staleTime: Infinity, staleTime: Infinity,
}) })

View File

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

View File

@ -1,12 +1,13 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useStateCheckEquality } from './use-state-check-equality' 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 type PersistenceOptions<T> = { key: string; store: PersistentStore<T> }
export interface PersistentStore<T> { export interface PersistentStore<T> {
get: (k: string) => T | undefined get: (k: string) => T | undefined
set: (k: string, v: T | undefined) => void set: (k: string, v: T | undefined) => void
readsUrl?: boolean
} }
const withURLParam = (location: Location, k: string, v?: string) => { const withURLParam = (location: Location, k: string, v?: string) => {
@ -61,6 +62,7 @@ export const urlParamStore = (router: NextRouter): PersistentStore<string> => ({
window.history.replaceState(updatedState, '', url) window.history.replaceState(updatedState, '', url)
} }
}, },
readsUrl: true,
}) })
export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({ export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({
@ -102,5 +104,20 @@ export const usePersistentState = <T>(
store.set(key, state) store.set(key, state)
} }
}, [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 return [state, setState] as const
} }

View File

@ -108,7 +108,6 @@ export const tournamentContractsByGroupSlugQuery = (slug: string) =>
query( query(
contracts, contracts,
where('groupSlugs', 'array-contains', slug), where('groupSlugs', 'array-contains', slug),
where('isResolved', '==', false),
orderBy('popularityScore', 'desc') 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 { ENV_CONFIG } from 'common/envs/constants'
import { PROD_CONFIG } from 'common/envs/prod' import { PROD_CONFIG } from 'common/envs/prod'
try { if (ENV_CONFIG.domain === PROD_CONFIG.domain) {
;(function (l, e, a, p) { try {
if (window.Sprig) return ;(function (l, e, a, p) {
window.Sprig = function (...args) { if (window.Sprig) return
S._queue.push(args) window.Sprig = function (...args) {
} S._queue.push(args)
const S = window.Sprig }
S.appId = a const S = window.Sprig
S._queue = [] S.appId = a
window.UserLeap = S S._queue = []
a = l.createElement('script') window.UserLeap = S
a.async = 1 a = l.createElement('script')
a.src = e + '?id=' + S.appId a.async = 1
p = l.getElementsByTagName('script')[0] a.src = e + '?id=' + S.appId
ENV_CONFIG.domain === PROD_CONFIG.domain && p.parentNode.insertBefore(a, p) p = l.getElementsByTagName('script')[0]
})(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId) p.parentNode.insertBefore(a, p)
} catch (error) { })(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId)
console.log('Error initializing Sprig, please complain to Barak', error) } catch (error) {
console.log('Error initializing Sprig, please complain to Barak', error)
}
} }
export function setUserId(userId: string): void { 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 { 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", "@amplitude/analytics-browser": "0.4.1",
"@floating-ui/react-dom-interactions": "0.9.2", "@floating-ui/react-dom-interactions": "0.9.2",
"@headlessui/react": "1.6.1", "@headlessui/react": "1.6.1",
"@hello-pangea/dnd": "16.0.0",
"@heroicons/react": "1.0.6", "@heroicons/react": "1.0.6",
"@react-query-firebase/firestore": "0.4.2", "@react-query-firebase/firestore": "0.4.2",
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.182",
@ -54,11 +55,10 @@
"next": "12.3.1", "next": "12.3.1",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"prosemirror-state": "1.4.1", "prosemirror-state": "1.4.1",
"react": "17.0.2", "react": "18.2.0",
"react-beautiful-dnd": "13.1.1",
"react-confetti": "6.0.1", "react-confetti": "6.0.1",
"react-dom": "17.0.2", "react-dom": "18.2.0",
"react-expanding-textarea": "2.3.5", "react-expanding-textarea": "2.3.6",
"react-hot-toast": "2.2.0", "react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1", "react-instantsearch-hooks-web": "6.24.1",
"react-masonry-css": "1.0.16", "react-masonry-css": "1.0.16",
@ -75,9 +75,8 @@
"@types/d3": "7.4.0", "@types/d3": "7.4.0",
"@types/lodash": "4.14.178", "@types/lodash": "4.14.178",
"@types/node": "16.11.11", "@types/node": "16.11.11",
"@types/react": "17.0.43", "@types/react": "18.0.21",
"@types/react-beautiful-dnd": "13.1.2", "@types/react-dom": "18.0.6",
"@types/react-dom": "17.0.2",
"@types/string-similarity": "^4.0.0", "@types/string-similarity": "^4.0.0",
"autoprefixer": "10.2.6", "autoprefixer": "10.2.6",
"critters": "0.0.16", "critters": "0.0.16",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,11 +42,7 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { Title } from 'web/components/title'
import { CreatePost } from 'web/components/create-post'
import { GroupOverview } from 'web/components/groups/group-overview' 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 const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -183,16 +179,6 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const postsPage = (
<>
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{posts && <GroupPosts posts={groupPosts} group={group} />}
</div>
</Col>
</>
)
const overviewPage = ( const overviewPage = (
<> <>
<GroupOverview <GroupOverview
@ -249,10 +235,6 @@ export default function GroupPage(props: {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboardTab, content: leaderboardTab,
}, },
{
title: 'Posts',
content: postsPage,
},
] ]
return ( 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 }) { function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)

View File

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

View File

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

View File

@ -1,11 +1,14 @@
import { CPMMBinaryContract } from 'common/contract'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { import {
StateElectionMarket, StateElectionMarket,
StateElectionMap, StateElectionMap,
} from 'web/components/usa-map/state-election-map' } from 'web/components/usa-map/state-election-map'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
const senateMidterms: StateElectionMarket[] = [ 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 ( return (
<Page className=""> <Page className="">
<Col className="items-center justify-center"> <Col className="items-center justify-center">
<Title text="2022 US Midterm Elections" className="mt-2" /> <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> <div className="mt-2 text-2xl">Senate</div>
<StateElectionMap markets={senateMidterms} /> <StateElectionMap
markets={senateMidterms}
contracts={senateContracts}
/>
<iframe <iframe
src="https://manifold.markets/TomShlomi/will-the-gop-control-the-us-senate" src="https://manifold.markets/TomShlomi/will-the-gop-control-the-us-senate"
title="Will the Democrats control the Senate after the Midterms?"
frameBorder="0" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<Spacer h={8} /> <Spacer h={8} />
<div className="mt-8 text-2xl">Governors</div> <div className="mt-8 text-2xl">Governors</div>
<StateElectionMap markets={governorMidterms} /> <StateElectionMap
markets={governorMidterms}
contracts={governorContracts}
/>
<iframe <iframe
src="https://manifold.markets/ManifoldMarkets/democrats-go-down-at-least-one-gove" 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" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<Spacer h={8} /> <Spacer h={8} />
<div className="mt-8 text-2xl">House</div> <div className="mt-8 text-2xl">House</div>
<iframe <iframe
src="https://manifold.markets/BoltonBailey/will-democrats-maintain-control-of" src="https://manifold.markets/BoltonBailey/will-democrats-maintain-control-of"
title="Will the Democrats control the House after the Midterms?"
frameBorder="0" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<Spacer h={8} /> <Spacer h={8} />
<div className="mt-8 text-2xl">Related markets</div> <div className="mt-8 text-2xl">Related markets</div>
<iframe <iframe
src="https://manifold.markets/BoltonBailey/balance-of-power-in-us-congress-aft" src="https://manifold.markets/BoltonBailey/balance-of-power-in-us-congress-aft"
title="Balance of Power in US Congress after 2022 Midterms"
frameBorder="0" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<iframe <iframe
src="https://manifold.markets/SG/will-a-democrat-win-the-2024-us-pre" 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" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<iframe <iframe
src="https://manifold.markets/Ibozz91/will-the-2022-alaska-house-general" 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?" title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?"
frameBorder="0" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
<iframe <iframe
src="https://manifold.markets/NathanpmYoung/how-many-supreme-court-justices-wil-1e597c3853ad" 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?" title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?"
frameBorder="0" frameBorder="0"
width={800} className="mt-8 flex h-96 w-full"
height={400}
className="mt-8"
></iframe> ></iframe>
</Col> </Col>
</Page> </Page>

View File

@ -38,6 +38,7 @@ import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { import {
BETTING_STREAK_BONUS_AMOUNT, BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX,
UNIQUE_BETTOR_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/economy' } from 'common/economy'
import { groupBy, sum, uniqBy } from 'lodash' import { groupBy, sum, uniqBy } from 'lodash'
@ -440,7 +441,8 @@ function IncomeNotificationItem(props: {
} else if (sourceType === 'tip') { } else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') { } 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 reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) { } else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested predictions returned as a` 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 { useUser } from 'web/hooks/use-user'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Subtitle } from 'web/components/subtitle'
export async function getStaticProps(props: { params: { slugs: string[] } }) { export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params const { slugs } = props.params
@ -75,7 +76,11 @@ export default function PostPage(props: {
url={'/post/' + post.slug} url={'/post/' + post.slug}
/> />
<div className="mx-auto w-full max-w-3xl "> <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> <Row>
<Col className="flex-1 px-2"> <Col className="flex-1 px-2">
<div className={'inline-flex'}> <div className={'inline-flex'}>
@ -202,25 +207,20 @@ export function RichEditPost(props: { post: Post }) {
</Row> </Row>
</> </>
) : ( ) : (
<> <Col>
<div className="relative"> <Content content={post.content} />
<div className="absolute top-0 right-0 z-10 space-x-2"> <Row className="place-content-end">
<Button <Button
color="gray" color="gray-white"
size="xs" size="xs"
onClick={() => { onClick={() => {
setEditing(true) setEditing(true)
editor?.commands.focus('end') editor?.commands.focus('end')
}} }}
> >
<PencilIcon className="inline h-4 w-4" /> <PencilIcon className="inline h-4 w-4" />
Edit </Button>
</Button> </Row>
</div> </Col>
<Content content={post.content} />
<Spacer h={2} />
</div>
</>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <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</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/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/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/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/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/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/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> </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: { colors: {
'red-25': '#FDF7F6', 'red-25': '#FDF7F6',
'greyscale-1': '#FBFBFF', 'greyscale-1': '#FBFBFF',
'greyscale-1.5': '#F4F4FB',
'greyscale-2': '#E7E7F4', 'greyscale-2': '#E7E7F4',
'greyscale-3': '#D8D8EB', 'greyscale-3': '#D8D8EB',
'greyscale-4': '#B1B1C7', 'greyscale-4': '#B1B1C7',

174
yarn.lock
View File

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