Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-10-13 18:02:33 -07:00 committed by GitHub
commit b85dbba895
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
123 changed files with 2698 additions and 1671 deletions

View File

@ -595,7 +595,8 @@ In addition to housing impact litigation, we provide free legal aid, education a
photo: 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', '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', preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.', 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, '-')

View File

@ -16,7 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba', cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets',
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
sprigEnvironmentId: 'Tu7kRZPm7daP', sprigEnvironmentId: 'Tu7kRZPm7daP',
} }

3
common/globalConfig.ts Normal file
View File

@ -0,0 +1,3 @@
export type GlobalConfig = {
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}

View File

@ -8,11 +8,13 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.191", "@tiptap/html": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"lodash": "4.17.21" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {

View File

@ -56,7 +56,8 @@ export const getLiquidityPoolPayouts = (
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool, subsidyPool } = contract const { pool, subsidyPool } = contract
const finalPool = pool[outcome] + subsidyPool const finalPool = pool[outcome] + (subsidyPool ?? 0)
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(liquidities)
@ -95,7 +96,8 @@ export const getLiquidityPoolProbPayouts = (
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool, subsidyPool } = contract const { pool, subsidyPool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool const finalPool = p * pool.YES + (1 - p) * pool.NO + (subsidyPool ?? 0)
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(liquidities)

View File

@ -8,6 +8,11 @@ export type Post = {
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
slug: string slug: string
// denormalized user fields
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string
} }
export type DateDoc = Post & { export type DateDoc = Post & {

View File

@ -1,4 +1,5 @@
import { generateText, JSONContent } from '@tiptap/core' import { generateText, JSONContent, Node } from '@tiptap/core'
import { generateJSON } from '@tiptap/html'
// Tiptap starter extensions // Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold' import { Bold } from '@tiptap/extension-bold'
@ -23,7 +24,7 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { cloneDeep, uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler' import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
@ -51,8 +52,28 @@ export function parseMentions(data: JSONContent): string[] {
return uniq(mentions) return uniq(mentions)
} }
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports // TODO: this is a hack to get around the fact that tiptap doesn't have a
// way to add a node view without bundling in tsx
function skippableComponent(name: string): Node<any, any> {
return Node.create({
name,
group: 'block',
content: 'inline*',
parseHTML() {
return [
{
tag: 'grid-cards-component',
},
]
},
})
}
const stringParseExts = [ const stringParseExts = [
// StarterKit extensions
Blockquote, Blockquote,
Bold, Bold,
BulletList, BulletList,
@ -69,38 +90,25 @@ const stringParseExts = [
Paragraph, Paragraph,
Strike, Strike,
Text, Text,
// other extensions
Image,
Link, Link,
Image.extend({ renderText: () => '[image]' }),
Mention, // user @mention Mention, // user @mention
Mention.extend({ name: 'contract-mention' }), // market %mention Mention.extend({ name: 'contract-mention' }), // market %mention
Iframe, Iframe.extend({
TiptapTweet, renderText: ({ node }) =>
TiptapSpoiler, '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
}),
skippableComponent('gridCardsComponent'),
TiptapTweet.extend({ renderText: () => '[tweet]' }),
TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }),
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {
if (!text) return '' if (!text) return ''
// remove spoiler tags. return generateText(text, stringParseExts)
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
} else if (current.type === 'image') {
current.text = '[Image]'
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
} else if (current.type === 'iframe') {
const src = current.attrs?.['src'] ? current.attrs['src'] : ''
current.text = '[Iframe]' + (src ? ` url:${src}` : '')
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
}
})
return generateText(newText, stringParseExts)
} }
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { export function htmlToRichText(html: string) {
data.content?.forEach((d) => dfs(d, f)) return generateJSON(html, stringParseExts)
f(data)
} }

View File

@ -680,6 +680,17 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
--data-raw '{"outcome": "YES", "shares": 10}' --data-raw '{"outcome": "YES", "shares": 10}'
``` ```
### `POST /v0/comment`
Creates a comment in the specified market. Only supports top-level comments for now.
Parameters:
- `contractId`: Required. The ID of the market to comment on.
- `content`: The comment to post, formatted as [TipTap json](https://tiptap.dev/guide/output#option-1-json), OR
- `html`: The comment to post, formatted as an HTML string, OR
- `markdown`: The comment to post, formatted as a markdown string.
### `GET /v0/bets` ### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending. Gets a list of bets, ordered by creation date descending.

View File

@ -23,6 +23,12 @@ service cloud.firestore {
allow read; allow read;
} }
match /globalConfig/globalConfig {
allow read;
allow update: if isAdmin()
allow create: if isAdmin()
}
match /users/{userId} { match /users/{userId} {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
@ -104,7 +110,7 @@ service cloud.firestore {
match /contracts/{contractId} { match /contracts/{contractId} {
allow read; allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;

View File

@ -15,9 +15,9 @@
"dev": "nodemon src/serve.ts", "dev": "nodemon src/serve.ts",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)", "verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
@ -26,11 +26,13 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.191", "@tiptap/html": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"cors": "2.8.5", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "express": "4.18.1",
@ -38,6 +40,7 @@
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"marked": "4.1.1",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2", "node-fetch": "2",
"stripe": "8.194.0", "stripe": "8.194.0",
@ -45,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/marked": "4.0.7",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3", "firebase-functions-test": "0.3.3",

View File

@ -0,0 +1,105 @@
import * as admin from 'firebase-admin'
import { getContract, getUser, log } from './utils'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { htmlToRichText } from '../../common/util/parse'
import { marked } from 'marked'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
contractId: z.string(),
content: contentSchema.optional(),
html: z.string().optional(),
markdown: z.string().optional(),
})
const MAX_COMMENT_JSON_LENGTH = 20000
// For now, only supports creating a new top-level comment on a contract.
// Replies, posts, chats are not supported yet.
export const createcomment = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { contractId, content, html, markdown } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
const contract = await getContract(contractId)
if (!creator) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
if (!contract) {
throw new APIError(400, 'No contract exists with the given ID.')
}
let contentJson = null
if (content) {
contentJson = content
} else if (html) {
console.log('html', html)
contentJson = htmlToRichText(html)
} else if (markdown) {
const markedParse = marked.parse(markdown)
log('parsed', markedParse)
contentJson = htmlToRichText(markedParse)
log('json', contentJson)
}
if (!contentJson) {
throw new APIError(400, 'No comment content provided.')
}
if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
)
}
const ref = firestore.collection(`contracts/${contractId}/comments`).doc()
const comment = removeUndefinedProps({
id: ref.id,
content: contentJson,
createdTime: Date.now(),
userId: creator.id,
userName: creator.name,
userUsername: creator.username,
userAvatarUrl: creator.avatarUrl,
// OnContract fields
commentType: 'contract',
contractId: contractId,
contractSlug: contract.slug,
contractQuestion: contract.question,
})
await ref.set(comment)
return { status: 'success', comment }
})

View File

@ -197,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
const needNotFollowContractReasons = ['tagged_user']
const stillFollowingContract = (userId: string) => { const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId) return contractFollowersIds.includes(userId)
} }
@ -205,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types reason: notification_reason_types
) => { ) => {
if (!stillFollowingContract(userId) || sourceUser.id == userId) return if (
(!stillFollowingContract(userId) &&
!needNotFollowContractReasons.includes(reason)) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(

View File

@ -100,6 +100,9 @@ export const createpost = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
contractSlug, contractSlug,
creatorName: creator.name,
creatorUsername: creator.username,
creatorAvatarUrl: creator.avatarUrl,
}) })
await postRef.create(post) await postRef.create(post)

View File

@ -65,6 +65,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { createcomment } from './create-comment'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
@ -94,6 +95,7 @@ const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addSubsidyFunction = toCloudFunction(addsubsidy) const addSubsidyFunction = toCloudFunction(addsubsidy)
const addCommentBounty = toCloudFunction(addcommentbounty) const addCommentBounty = toCloudFunction(addcommentbounty)
const createCommentFunction = toCloudFunction(createcomment)
const awardCommentBounty = toCloudFunction(awardcommentbounty) const awardCommentBounty = toCloudFunction(awardcommentbounty)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
@ -130,6 +132,7 @@ export {
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
createCommentFunction as createcomment,
addCommentBounty as addcommentbounty, addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty, awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics, updateMetricsFunction as updatemetrics,

View File

@ -39,7 +39,8 @@ export const onUpdateContract = functions.firestore
async function handleResolvedContract(contract: Contract) { async function handleResolvedContract(contract: Contract) {
if ( if (
(contract.uniqueBettorCount ?? 0) < (contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
contract.resolution === 'CANCEL'
) )
return return

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { mapValues, groupBy, sumBy } from 'lodash' import { mapValues, groupBy, sumBy, uniqBy } from 'lodash'
import { import {
Contract, Contract,
@ -15,14 +15,14 @@ import {
getValues, getValues,
isProd, isProd,
log, log,
payUser, payUsers,
payUsersMultipleTransactions,
revalidateStaticProps, revalidateStaticProps,
} from './utils' } from './utils'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
groupPayoutsByUser, groupPayoutsByUser,
Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isAdmin, isManifoldId } from '../../common/envs/constants' import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
@ -36,6 +36,7 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { User } from 'common/user'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -89,13 +90,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
if (!contractSnap.exists) if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID') throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract const { creatorId } = contract
const firebaseUser = await admin.auth().getUser(auth.uid) const firebaseUser = await admin.auth().getUser(auth.uid)
const { value, resolutions, probabilityInt, outcome } = getResolutionParams( const resolutionParams = getResolutionParams(contract, req.body)
contract,
req.body
)
if ( if (
creatorId !== auth.uid && creatorId !== auth.uid &&
@ -109,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const creator = await getUser(creatorId) const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found') if (!creator) throw new APIError(500, 'Creator not found')
return await resolveMarket(contract, creator, resolutionParams)
})
export const resolveMarket = async (
contract: Contract,
creator: User,
{ value, resolutions, probabilityInt, outcome }: ResolutionParams
) => {
const { creatorId, closeTime, id: contractId } = contract
const resolutionProbability = const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -131,15 +139,19 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
(doc) => doc.data() as LiquidityProvision (doc) => doc.data() as LiquidityProvision
) )
const { payouts, creatorPayout, liquidityPayouts, collectedFees } = const {
getPayouts( payouts: traderPayouts,
outcome, creatorPayout,
contract, liquidityPayouts,
bets, collectedFees,
liquidities, } = getPayouts(
resolutions, outcome,
resolutionProbability contract,
) bets,
liquidities,
resolutions,
resolutionProbability
)
const updatedContract = { const updatedContract = {
...contract, ...contract,
@ -156,33 +168,47 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
subsidyPool: 0, subsidyPool: 0,
} }
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets) const loanPayouts = getLoanPayouts(openBets)
const payoutsWithoutLoans = [
{ userId: creatorId, payout: creatorPayout, deposit: creatorPayout },
...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })),
...traderPayouts,
]
const payouts = [...payoutsWithoutLoans, ...loanPayouts]
if (!isProd()) if (!isProd())
console.log( console.log(
'payouts:', 'trader payouts:',
payouts, traderPayouts,
'creator payout:', 'creator payout:',
creatorPayout, creatorPayout,
'liquidity payout:' 'liquidity payout:',
liquidityPayouts,
'loan payouts:',
loanPayouts
) )
if (creatorPayout) const userCount = uniqBy(payouts, 'userId').length
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) const contractDoc = firestore.doc(`contracts/${contractId}`)
await processPayouts(liquidityPayouts, true) if (userCount <= 499) {
await firestore.runTransaction(async (transaction) => {
payUsers(transaction, payouts)
transaction.update(contractDoc, updatedContract)
})
} else {
await payUsersMultipleTransactions(payouts)
await contractDoc.update(updatedContract)
}
console.log('contract ', contractId, 'resolved to:', outcome)
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract)) await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans)
const userInvestments = mapValues( const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
@ -209,18 +235,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
) )
return updatedContract return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts)
const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) =>
payUser(userId, payout, isDeposit)
)
return await Promise.all(payoutPromises)
.catch((e) => ({ status: 'error', message: e }))
.then(() => ({ status: 'success' }))
} }
function getResolutionParams(contract: Contract, body: string) { function getResolutionParams(contract: Contract, body: string) {
@ -287,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
} }
type ResolutionParams = ReturnType<typeof getResolutionParams>
function validateAnswer( function validateAnswer(
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
answer: number answer: number

View File

@ -0,0 +1,24 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const contractsRef = firestore.collection('contracts')
contractsRef.get().then(async (contractsSnaps) => {
console.log(`Loaded ${contractsSnaps.size} contracts.`)
const needsFilling = contractsSnaps.docs.filter((ct) => {
return !('subsidyPool' in ct.data())
})
console.log(`Found ${needsFilling.length} contracts to update.`)
await Promise.all(
needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 }))
)
console.log(`Updated all contracts.`)
})
}

View File

@ -0,0 +1,59 @@
import { initAdmin } from './script-init'
initAdmin()
import { zip } from 'lodash'
import { filterDefined } from 'common/util/array'
import { resolveMarket } from '../resolve-market'
import { getContract, getUser } from '../utils'
if (require.main === module) {
const contractIds = process.argv.slice(2)
if (contractIds.length === 0) {
throw new Error('No contract ids provided')
}
resolveMarketsAgain(contractIds).then(() => process.exit(0))
}
async function resolveMarketsAgain(contractIds: string[]) {
const maybeContracts = await Promise.all(contractIds.map(getContract))
if (maybeContracts.some((c) => !c)) {
throw new Error('Invalid contract id')
}
const contracts = filterDefined(maybeContracts)
const maybeCreators = await Promise.all(
contracts.map((c) => getUser(c.creatorId))
)
if (maybeCreators.some((c) => !c)) {
throw new Error('No creator found')
}
const creators = filterDefined(maybeCreators)
if (
!contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO')
) {
throw new Error('Only YES or NO resolutions supported')
}
const resolutionParams = contracts.map((c) => ({
outcome: c.resolution as string,
value: undefined,
probabilityInt: undefined,
resolutions: undefined,
}))
const params = zip(contracts, creators, resolutionParams)
for (const [contract, creator, resolutionParams] of params) {
if (contract && creator && resolutionParams) {
console.log('Resolving', contract.question)
try {
await resolveMarket(contract, creator, resolutionParams)
} catch (e) {
console.log(e)
}
}
}
console.log(`Resolved all contracts.`)
}

View File

@ -19,6 +19,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { createcomment } from './create-comment'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
@ -53,6 +54,7 @@ addJsonEndpointRoute('/transact', transact)
addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createuser', createuser)
addJsonEndpointRoute('/createanswer', createanswer) addJsonEndpointRoute('/createanswer', createanswer)
addJsonEndpointRoute('/createcomment', createcomment)
addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/placebet', placebet)
addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/cancelbet', cancelbet)
addJsonEndpointRoute('/sellbet', sellbet) addJsonEndpointRoute('/sellbet', sellbet)

View File

@ -1,7 +1,8 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import fetch from 'node-fetch' import fetch from 'node-fetch'
import { FieldValue, Transaction } from 'firebase-admin/firestore'
import { chunk, groupBy, mapValues, sumBy } from 'lodash'
import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
@ -128,38 +129,29 @@ export const getUserByUsername = async (username: string) => {
return snap.empty ? undefined : (snap.docs[0].data() as User) return snap.empty ? undefined : (snap.docs[0].data() as User)
} }
const firestore = admin.firestore()
const updateUserBalance = ( const updateUserBalance = (
transaction: Transaction,
userId: string, userId: string,
delta: number, balanceDelta: number,
isDeposit = false depositDelta: number
) => { ) => {
const firestore = admin.firestore() const userDoc = firestore.doc(`users/${userId}`)
return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) return
const user = userSnap.data() as User
const newUserBalance = user.balance + delta // Note: Balance is allowed to go negative.
transaction.update(userDoc, {
// if (newUserBalance < 0) balance: FieldValue.increment(balanceDelta),
// throw new Error( totalDeposits: FieldValue.increment(depositDelta),
// `User (${userId}) balance cannot be negative: ${newUserBalance}`
// )
if (isDeposit) {
const newTotalDeposits = (user.totalDeposits || 0) + delta
transaction.update(userDoc, { totalDeposits: newTotalDeposits })
}
transaction.update(userDoc, { balance: newUserBalance })
}) })
} }
export const payUser = (userId: string, payout: number, isDeposit = false) => { export const payUser = (userId: string, payout: number, isDeposit = false) => {
if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout)
return updateUserBalance(userId, payout, isDeposit) return firestore.runTransaction(async (transaction) => {
updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0)
})
} }
export const chargeUser = ( export const chargeUser = (
@ -170,7 +162,67 @@ export const chargeUser = (
if (!isFinite(charge) || charge <= 0) if (!isFinite(charge) || charge <= 0)
throw new Error('User charge is not positive: ' + charge) throw new Error('User charge is not positive: ' + charge)
return updateUserBalance(userId, -charge, isAnte) return payUser(userId, -charge, isAnte)
}
const checkAndMergePayouts = (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
for (const { payout, deposit } of payouts) {
if (!isFinite(payout)) {
throw new Error('Payout is not finite: ' + payout)
}
if (deposit !== undefined && !isFinite(deposit)) {
throw new Error('Deposit is not finite: ' + deposit)
}
}
const groupedPayouts = groupBy(payouts, 'userId')
return Object.values(
mapValues(groupedPayouts, (payouts, userId) => ({
userId,
payout: sumBy(payouts, 'payout'),
deposit: sumBy(payouts, (p) => p.deposit ?? 0),
}))
)
}
// Max 500 users in one transaction.
export const payUsers = (
transaction: Transaction,
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
for (const { userId, payout, deposit } of mergedPayouts) {
updateUserBalance(transaction, userId, payout, deposit)
}
}
export const payUsersMultipleTransactions = async (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
const payoutChunks = chunk(mergedPayouts, 500)
for (const payoutChunk of payoutChunks) {
await firestore.runTransaction(async (transaction) => {
for (const { userId, payout, deposit } of payoutChunk) {
updateUserBalance(transaction, userId, payout, deposit)
}
})
}
} }
export const getContractPath = (contract: Contract) => { export const getContractPath = (contract: Contract) => {

3
web/.gitignore vendored
View File

@ -2,4 +2,5 @@
.next .next
node_modules node_modules
out out
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.env*

View File

@ -35,7 +35,7 @@ export function AddFundsModal(props: {
<div className="text-xl">{manaToUSD(amountSelected)}</div> <div className="text-xl">{manaToUSD(amountSelected)}</div>
</div> </div>
<div className="modal-action"> <div className="flex">
<Button color="gray-white" onClick={() => setOpen(false)}> <Button color="gray-white" onClick={() => setOpen(false)}>
Back Back
</Button> </Button>

View File

@ -7,6 +7,8 @@ import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row' import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal' import { AddFundsModal } from './add-funds-modal'
import { Input } from './input' import { Input } from './input'
import Slider from 'rc-slider'
import 'rc-slider/assets/index.css'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -40,18 +42,13 @@ export function AmountInput(props: {
return ( return (
<> <>
<Col className={className}> <Col className={clsx('relative', className)}>
<label className="font-sm md:font-lg relative"> <label className="font-sm md:font-lg relative">
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<Input <Input
className={clsx( className={clsx('w-24 pl-9 !text-base md:w-auto', inputClassName)}
'pl-9',
error && 'input-error',
'w-24 md:w-auto',
inputClassName
)}
ref={inputRef} ref={inputRef}
type="text" type="text"
pattern="[0-9]*" pattern="[0-9]*"
@ -59,13 +56,14 @@ export function AmountInput(props: {
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
value={amount ?? ''} value={amount ?? ''}
error={!!error}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
/> />
</label> </label>
{error && ( {error && (
<div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="absolute -bottom-5 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
<> <>
Not enough funds. Not enough funds.
@ -149,7 +147,7 @@ export function BuyAmountInput(props: {
return ( return (
<> <>
<Row className="gap-4"> <Row className="items-center gap-4">
<AmountInput <AmountInput
amount={amount} amount={amount}
onChange={onAmountChange} onChange={onAmountChange}
@ -161,14 +159,23 @@ export function BuyAmountInput(props: {
inputRef={inputRef} inputRef={inputRef}
/> />
{showSlider && ( {showSlider && (
<input <Slider
type="range" min={0}
min="0" max={205}
max="205"
value={getRaw(amount ?? 0)} value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))} onChange={(value) => onAmountChange(parseRaw(value as number))}
className="range range-lg only-thumb my-auto align-middle xl:hidden" className="mx-4 !h-4 xl:hidden [&>.rc-slider-rail]:bg-gray-200 [&>.rc-slider-track]:bg-indigo-400 [&>.rc-slider-handle]:bg-indigo-400"
step="5" railStyle={{ height: 16, top: 0, left: 0 }}
trackStyle={{ height: 16, top: 0 }}
handleStyle={{
height: 32,
width: 32,
opacity: 1,
border: 'none',
boxShadow: 'none',
top: -2,
}}
step={5}
/> />
)} )}
</Row> </Row>

View File

@ -126,7 +126,10 @@ export function AnswerBetPanel(props: {
</div> </div>
{!isModal && ( {!isModal && (
<button className="btn-ghost btn-circle" onClick={closePanel}> <button
className="hover:bg-greyscale-2 rounded-full"
onClick={closePanel}
>
<XIcon <XIcon
className="mx-auto h-8 w-8 text-gray-500" className="mx-auto h-8 w-8 text-gray-500"
aria-hidden="true" aria-hidden="true"

View File

@ -93,15 +93,15 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-2xl', 'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500' tradingAllowed(contract) ? 'text-teal-500' : 'text-gray-500'
)} )}
> >
{probPercent} {probPercent}
</div> </div>
))} ))}
{showChoice ? ( {showChoice ? (
<div className="form-control py-1"> <div className="flex flex-col py-1">
<label className="label cursor-pointer gap-3"> <label className="cursor-pointer gap-3 px-1 py-2">
<span className="">Choose this answer</span> <span className="">Choose this answer</span>
{showChoice === 'radio' && ( {showChoice === 'radio' && (
<input <input
@ -144,7 +144,7 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-xl', 'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700' resolution === 'MKT' ? 'text-blue-700' : 'text-teal-600'
)} )}
> >
Chosen{' '} Chosen{' '}

View File

@ -10,6 +10,7 @@ import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button' import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user' import { BETTOR, PAST_BETS } from 'common/user'
import { Button } from '../button'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
isAdmin: boolean isAdmin: boolean
@ -109,14 +110,14 @@ export function AnswerResolvePanel(props: {
)} )}
> >
{resolveOption && ( {resolveOption && (
<button <Button
className="btn btn-ghost" color="gray-white"
onClick={() => { onClick={() => {
setResolveOption(undefined) setResolveOption(undefined)
}} }}
> >
Clear Clear
</button> </Button>
)} )}
<ResolveConfirmationButton <ResolveConfirmationButton

View File

@ -23,14 +23,16 @@ import { Linkify } from 'web/components/linkify'
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 { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice' import { useChartAnswers } from '../charts/contract/choice'
import { ChatIcon } from '@heroicons/react/outline'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const isAdmin = useAdmin() const isAdmin = useAdmin()
const { contract } = props const { contract, onAnswerCommentClick } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } = const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract contract
const [showAllAnswers, setShowAllAnswers] = useState(false) const [showAllAnswers, setShowAllAnswers] = useState(false)
@ -138,6 +140,7 @@ export function AnswersPanel(props: {
answer={item} answer={item}
contract={contract} contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)} colorIndex={colorSortedAnswer.indexOf(item.text)}
onAnswerCommentClick={onAnswerCommentClick}
/> />
))} ))}
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
@ -183,14 +186,18 @@ function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
colorIndex: number | undefined colorIndex: number | undefined
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const { answer, contract, colorIndex } = props const { answer, contract, colorIndex, onAnswerCommentClick } = props
const { username, avatarUrl, 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 = const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7' colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex] + '55' // semi-transparent
: '#B1B1C755'
const colorWidth = 100 * Math.max(prob, 0.01)
return ( return (
<Col className="my-1 px-2"> <Col className="my-1 px-2">
@ -206,9 +213,12 @@ function OpenAnswer(props: {
<Col <Col
className={clsx( className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all', 'relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5' tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)} )}
style={{
background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}}
> >
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3"> <Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row> <Row>
@ -217,10 +227,7 @@ function OpenAnswer(props: {
username={username} username={username}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
/> />
<Linkify <Linkify className="text-md whitespace-pre-line" text={text} />
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
</Row> </Row>
<Row className="gap-2"> <Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div> <div className="my-auto text-xl">{probPercent}</div>
@ -234,13 +241,16 @@ function OpenAnswer(props: {
BUY BUY
</Button> </Button>
)} )}
{
<button
className="p-1"
onClick={() => onAnswerCommentClick(answer)}
>
<ChatIcon className="text-greyscale-4 hover:text-greyscale-6 h-5 w-5 transition-colors" />
</button>
}
</Row> </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> </Col>
) )

View File

@ -197,17 +197,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</> </>
)} )}
{user ? ( {user ? (
<button <Button
className={clsx( color="green"
'btn mt-2', size="lg"
canSubmit ? 'btn-outline' : 'btn-disabled', loading={isSubmitting}
isSubmitting && 'loading'
)}
disabled={!canSubmit} disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')} onClick={withTracking(submitAnswer, 'submit answer')}
> >
Submit Submit
</button> </Button>
) : ( ) : (
text && ( text && (
<Button <Button

View File

@ -5,7 +5,6 @@ import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -37,10 +36,17 @@ export function AwardBountyButton(prop: {
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div /> if (!canUp) return <div />
return ( return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}> <Row
<TextButton className={'font-bold'} onClick={submit}> className={clsx('my-auto items-center gap-0.5', !canUp ? '-ml-6' : '')}
>
<button
className={
'rounded-full border border-indigo-400 bg-indigo-50 py-0.5 px-2 text-xs text-indigo-400 transition-colors hover:bg-indigo-400 hover:text-white'
}
onClick={submit}
>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton> </button>
</Row> </Row>
) )
} }

View File

@ -92,10 +92,7 @@ export function BetInline(props: {
/> />
<BuyAmountInput <BuyAmountInput
className="-mb-4" className="-mb-4"
inputClassName={clsx( inputClassName="w-20 !text-base"
'input-sm w-20 !text-base',
error && 'input-error'
)}
amount={amount} amount={amount}
onChange={setAmount} onChange={setAmount}
error="" // handle error ourselves error="" // handle error ourselves

View File

@ -47,6 +47,7 @@ import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { Button } from './button'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -270,7 +271,7 @@ export function BuyPanel(props: {
}) })
} }
const betDisabled = isSubmitting || !betAmount || error const betDisabled = isSubmitting || !betAmount || !!error
const { newPool, newP, newBet } = getBinaryCpmmBetInfo( const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
outcome ?? 'YES', outcome ?? 'YES',
@ -469,7 +470,6 @@ function LimitOrderPanel(props: {
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>() const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>() const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -493,7 +493,7 @@ function LimitOrderPanel(props: {
!betAmount || !betAmount ||
rangeError || rangeError ||
outOfRangeError || outOfRangeError ||
error || !!error ||
(!hasYesLimitBet && !hasNoLimitBet) (!hasYesLimitBet && !hasNoLimitBet)
const yesLimitProb = const yesLimitProb =
@ -631,9 +631,9 @@ function LimitOrderPanel(props: {
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 mb-4 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -641,10 +641,11 @@ function LimitOrderPanel(props: {
prob={lowLimitProb} prob={lowLimitProb}
setProb={setLowLimitProb} setProb={setLowLimitProb}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
placeholder="10"
/> />
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -652,6 +653,7 @@ function LimitOrderPanel(props: {
prob={highLimitProb} prob={highLimitProb}
setProb={setHighLimitProb} setProb={setHighLimitProb}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
placeholder="90"
/> />
</Col> </Col>
</Row> </Row>
@ -783,22 +785,18 @@ function LimitOrderPanel(props: {
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />} {(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && ( {user && (
<button <Button
className={clsx( size="xl"
'btn flex-1', disabled={betDisabled}
betDisabled color={'indigo'}
? 'btn-disabled' loading={isSubmitting}
: betChoice === 'YES' className="flex-1"
? 'btn-primary' onClick={submitBet}
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
: `Submit order${hasTwoBets ? 's' : ''}`} : `Submit order${hasTwoBets ? 's' : ''}`}
</button> </Button>
)} )}
</Col> </Col>
) )
@ -984,11 +982,11 @@ export function SellPanel(props: {
<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 amount Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span> <span className="text-gray-700">{formatMoney(saleValue)}</span>
</Row> </Row>
<Row className="items-center justify-between gap-2 text-gray-500"> <Row className="items-center justify-between gap-2 text-gray-500">
Profit Profit
<span className="text-neutral">{formatMoney(profit)}</span> <span className="text-gray-700">{formatMoney(profit)}</span>
</Row> </Row>
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<div className="text-gray-500"> <div className="text-gray-500">
@ -1004,11 +1002,11 @@ export function SellPanel(props: {
<> <>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500"> <Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment Loan payment
<span className="text-neutral">{formatMoney(-loanPaid)}</span> <span className="text-gray-700">{formatMoney(-loanPaid)}</span>
</Row> </Row>
<Row className="items-center justify-between gap-2 text-gray-500"> <Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds Net proceeds
<span className="text-neutral">{formatMoney(netProceeds)}</span> <span className="text-gray-700">{formatMoney(netProceeds)}</span>
</Row> </Row>
</> </>
)} )}

View File

@ -52,6 +52,8 @@ import {
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline' import { ExclamationIcon } from '@heroicons/react/outline'
import { Select } from './select'
import { Table } from './table'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -200,21 +202,19 @@ export function BetsList(props: { user: User }) {
</Row> </Row>
<Row className="gap-2"> <Row className="gap-2">
<select <Select
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as BetFilter)} onChange={(e) => setFilter(e.target.value as BetFilter)}
> >
<option value="open">Open</option> <option value="open">Active</option>
<option value="limit_bet">Limit orders</option> <option value="limit_bet">Limit orders</option>
<option value="sold">Sold</option> <option value="sold">Sold</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="all">All</option> <option value="all">All</option>
</select> </Select>
<select <Select
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as BetSort)} onChange={(e) => setSort(e.target.value as BetSort)}
> >
@ -222,7 +222,7 @@ export function BetsList(props: { user: User }) {
<option value="value">Value</option> <option value="value">Value</option>
<option value="profit">Profit</option> <option value="profit">Profit</option>
<option value="closeTime">Close date</option> <option value="closeTime">Close date</option>
</select> </Select>
</Row> </Row>
</Col> </Col>
@ -451,7 +451,7 @@ export function ContractBetsTable(props: {
</> </>
)} )}
<table className="table-zebra table-compact table w-full text-gray-500"> <Table>
<thead> <thead>
<tr className="p-2"> <tr className="p-2">
<th></th> <th></th>
@ -480,7 +480,7 @@ export function ContractBetsTable(props: {
/> />
))} ))}
</tbody> </tbody>
</table> </Table>
</div> </div>
) )
} }
@ -551,7 +551,7 @@ function BetRow(props: {
return ( return (
<tr> <tr>
<td className="text-neutral"> <td className="text-gray-700">
{isYourBet && {isYourBet &&
!isCPMM && !isCPMM &&
!isResolved && !isResolved &&

View File

@ -13,7 +13,6 @@ export type ColorType =
| 'gray-outline' | 'gray-outline'
| 'gradient' | 'gradient'
| 'gray-white' | 'gray-white'
| 'highlight-blue'
const sizeClasses = { const sizeClasses = {
'2xs': 'px-2 py-1 text-xs', '2xs': 'px-2 py-1 text-xs',
@ -27,7 +26,7 @@ const sizeClasses = {
export function buttonClass(size: SizeType, color: ColorType | 'override') { export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx( return clsx(
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed', 'font-md inline-flex items-center justify-center rounded-md ring-inset shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size], sizeClasses[size],
color === 'green' && color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600', 'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
@ -42,13 +41,11 @@ export function buttonClass(size: SizeType, color: ColorType | 'override') {
color === 'gray' && color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50', 'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' && color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50', 'ring-2 ring-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 hover:text-white disabled:opacity-50',
color === 'gradient' && 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', 'disabled:bg-greyscale-2 bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50', 'text-greyscale-6 hover:bg-greyscale-2 shadow-none disabled:opacity-50'
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
) )
} }
@ -85,3 +82,39 @@ export function Button(props: {
</button> </button>
) )
} }
export function IconButton(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
className,
onClick,
size = 'md',
type = 'button',
disabled = false,
loading,
} = props
return (
<button
type={type}
className={clsx(
'inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
'disabled:text-greyscale-2 text-greyscale-6 hover:text-indigo-600',
className
)}
disabled={disabled || loading}
onClick={onClick}
>
{children}
</button>
)
}

View File

@ -171,7 +171,7 @@ function CreateChallengeForm(props: {
<div>You'll bet:</div> <div>You'll bet:</div>
<Row <Row
className={ className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' 'w-full max-w-xs items-center justify-between gap-4 pr-3'
} }
> >
<AmountInput <AmountInput

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash' import { last, range, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape' import { curveStepAfter } from 'd3-shape'
@ -19,83 +19,36 @@ 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'
export const CATEGORY_COLORS = [ type ChoiceContract = FreeResponseContract | MultipleChoiceContract
'#7eb0d5',
'#fd7f6f', export const CHOICE_ANSWER_COLORS = [
'#b2e061', '#97C1EB',
'#bd7ebe', '#F39F83',
'#ffb55a', '#F9EBA5',
'#ffee65', '#FFC7D2',
'#beb9db', '#C7ECFF',
'#fdcce5', '#8CDEC7',
'#8bd3c7', '#DBE96F',
'#bddfb7',
'#e2e3f3',
'#fafafa',
'#9fcdeb',
'#d3d3d3',
'#b1a296',
'#e1bdb6',
'#f2dbc0',
'#fae5d3',
'#c5e0ec',
'#e0f0ff',
'#ffddcd',
'#fbd5e2',
'#f2e7e5',
'#ffe7ba',
'#eed9c4',
'#ea9999',
'#f9cb9c',
'#ffe599',
'#b6d7a8',
'#a2c4c9',
'#9fc5e8',
'#b4a7d6',
'#d5a6bd',
'#e06666',
'#f6b26b',
'#ffd966',
'#93c47d',
'#76a5af',
'#6fa8dc',
'#8e7cc3',
'#c27ba0',
'#cc0000',
'#e69138',
'#f1c232',
'#6aa84f',
'#45818e',
'#3d85c6',
'#674ea7',
'#a64d79',
'#990000',
'#b45f06',
'#bf9000',
] ]
export const CHOICE_OTHER_COLOR = '#CCC'
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = ( const getAnswers = (contract: ChoiceContract) => {
contract: FreeResponseContract | MultipleChoiceContract, const { answers, outcomeType } = contract
topN: number const validAnswers = answers.filter(
) => { (answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
const { answers, outcomeType, totalBets } = contract )
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
)
})
return sortBy( return sortBy(
validAnswers, validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id) (answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN) )
} }
const getBetPoints = (answers: Answer[], bets: Bet[]) => { const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
const sortedBets = sortBy(bets, (b) => b.createdTime) const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries( const sharesByOutcome = Object.fromEntries(
@ -109,11 +62,14 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sharesSquared = sum( const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2) Object.values(sharesByOutcome).map((shares) => shares ** 2)
) )
points.push({ const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), if (topN != null && answers.length > topN) {
obj: bet, const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
}) points.push({ x: new Date(bet.createdTime), y, obj: bet })
} else {
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
}
} }
return points return points
} }
@ -141,17 +97,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
) )
} }
export function useChartAnswers( export function useChartAnswers(contract: ChoiceContract) {
contract: FreeResponseContract | MultipleChoiceContract return useMemo(() => getAnswers(contract), [contract])
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
} }
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: ChoiceContract
bets: Bet[] bets: Bet[]
width: number width: number
height: number height: number
@ -160,18 +111,33 @@ 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 = useChartAnswers(contract) const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
const data = useMemo( const betPoints = useMemo(
() => [ () => getBetPoints(answers, bets, topN),
{ x: new Date(start), y: answers.map((_) => 0) }, [answers, bets, topN]
)
const endProbs = useMemo(
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
[answers, contract]
)
const data = useMemo(() => {
const yCount = answers.length > topN ? topN + 1 : topN
const startY = range(0, yCount).map((_) => 0)
const endY =
answers.length > topN
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
: endProbs
return [
{ x: new Date(start), y: startY },
...betPoints, ...betPoints,
{ {
x: new Date(end ?? Date.now() + DAY_MS), x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)), y: endY,
}, },
], ]
[answers, contract, betPoints, start, end] }, [answers.length, topN, betPoints, endProbs, start, end])
)
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
end, end,
last(betPoints)?.x?.getTime(), last(betPoints)?.x?.getTime(),
@ -188,8 +154,8 @@ export const ChoiceContractChart = (props: {
const d = xScale.invert(x) 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: CHOICE_ALL_COLORS[i],
label: answers[i].text, label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
value: formatPct(p), value: formatPct(p),
p, p,
})), })),
@ -221,7 +187,7 @@ export const ChoiceContractChart = (props: {
yScale={yScale} yScale={yScale}
yKind="percent" yKind="percent"
data={data} data={data}
colors={CATEGORY_COLORS} colors={CHOICE_ALL_COLORS}
curve={curveStepAfter} curve={curveStepAfter}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip} Tooltip={ChoiceTooltip}

View File

@ -1,12 +1,17 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid' import { PaperAirplaneIcon, XCircleIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Answer } from 'common/answer'
import { AnyContractType, Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments' import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import Curve from 'web/public/custom-components/curve'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor' import { TextEditor, useTextEditor } from './editor'
import { CommentsAnswer } from './feed/feed-answer-comment-group'
import { ContractCommentInput } from './feed/feed-comments'
import { Row } from './layout/row' import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
@ -72,6 +77,40 @@ export function CommentInput(props: {
</Row> </Row>
) )
} }
export function AnswerCommentInput(props: {
contract: Contract<AnyContractType>
answerResponse: Answer
onCancelAnswerResponse?: () => void
}) {
const { contract, answerResponse, onCancelAnswerResponse } = props
const replyTo = {
id: answerResponse.id,
username: answerResponse.username,
}
return (
<>
<CommentsAnswer answer={answerResponse} contract={contract} />
<Row>
<div className="ml-1">
<Curve size={28} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="relative w-full pt-1">
<ContractCommentInput
contract={contract}
parentAnswerOutcome={answerResponse.number.toString()}
replyTo={replyTo}
onSubmitComment={onCancelAnswerResponse}
/>
<button onClick={onCancelAnswerResponse}>
<div className="absolute -top-1 -right-2 h-4 w-4 rounded-full bg-white" />
<XCircleIcon className="text-greyscale-5 hover:text-greyscale-6 absolute -top-1 -right-2 h-5 w-5" />
</button>
</div>
</Row>
</>
)
}
export function CommentInputTextArea(props: { export function CommentInputTextArea(props: {
user: User | undefined | null user: User | undefined | null
@ -123,7 +162,7 @@ export function CommentInputTextArea(props: {
attrs: { label: replyTo.username, id: replyTo.id }, attrs: { label: replyTo.username, id: replyTo.id },
}) })
.insertContent(' ') .insertContent(' ')
.focus() .focus(undefined, { scrollIntoView: false })
.run() .run()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -0,0 +1,29 @@
import clsx from 'clsx'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import { Row } from '../layout/row'
export function ReplyToggle(props: {
seeReplies: boolean
numComments: number
onClick: () => void
}) {
const { seeReplies, numComments, onClick } = props
return (
<button
className={clsx(
'text-left text-sm text-indigo-600',
numComments === 0 ? 'hidden' : ''
)}
onClick={onClick}
>
<Row className="items-center gap-1">
<div>
{numComments} {numComments === 1 ? 'Reply' : 'Replies'}
</div>
<TriangleDownFillIcon
className={clsx('h-2 w-2', seeReplies ? 'rotate-180' : '')}
/>
</Row>
</button>
)
}

View File

@ -42,6 +42,7 @@ import { Button } from './button'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import { Input } from './input' import { Input } from './input'
import { Select } from './select'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
@ -437,7 +438,7 @@ function ContractSearchControls(props: {
} }
return ( return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}> <Col className={clsx('bg-greyscale-1 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<Input <Input
type="text" type="text"
@ -543,8 +544,7 @@ export function SearchFilters(props: {
return ( return (
<div className={className}> <div className={className}>
<select <Select
className="select select-bordered"
value={filter} value={filter}
onChange={(e) => selectFilter(e.target.value as filter)} onChange={(e) => selectFilter(e.target.value as filter)}
> >
@ -552,10 +552,9 @@ export function SearchFilters(props: {
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="all">All</option> <option value="all">All</option>
</select> </Select>
{!hideOrderSelector && ( {!hideOrderSelector && (
<select <Select
className="select select-bordered"
value={sort} value={sort}
onChange={(e) => selectSort(e.target.value as Sort)} onChange={(e) => selectSort(e.target.value as Sort)}
> >
@ -564,7 +563,7 @@ export function SearchFilters(props: {
{option.label} {option.label}
</option> </option>
))} ))}
</select> </Select>
)} )}
</div> </div>
) )

View File

@ -4,7 +4,6 @@ import { useState } from 'react'
import { addCommentBounty } from 'web/lib/firebase/api' import { addCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
@ -64,7 +63,7 @@ export function CommentBountyDialog(props: {
<Row className={'items-center gap-2'}> <Row className={'items-center gap-2'}>
<Button <Button
className={clsx('ml-2', isLoading && 'btn-disabled')} className="ml-2"
onClick={submit} onClick={submit}
disabled={isLoading} disabled={isLoading}
color={'blue'} color={'blue'}

View File

@ -3,8 +3,8 @@ import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { import {
formatLargeNumber, formatLargeNumber,
formatMoney,
formatPercent, formatPercent,
formatWithCommas,
} from 'common/util/format' } from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -39,9 +39,10 @@ import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric' 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 { ProbOrNumericChange } from './prob-change-table'
import { Card } from '../card' import { Card } from '../card'
import { ProfitBadgeMana } from '../profit-badge' import { floatingEqual } from 'common/util/math'
import { ENV_CONFIG } from 'common/envs/constants'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -399,13 +400,24 @@ export function ContractCardProbChange(props: {
className?: string className?: string
}) { }) {
const { noLinkAvatar, showPosition, className } = props const { noLinkAvatar, showPosition, className } = props
const yesOutcomeLabel =
props.contract.outcomeType === 'PSEUDO_NUMERIC' ? 'HIGHER' : 'YES'
const noOutcomeLabel =
props.contract.outcomeType === 'PSEUDO_NUMERIC' ? 'LOWER' : 'NO'
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
const user = useUser() const user = useUser()
const metrics = useUserContractMetrics(user?.id, contract.id) const metrics = useUserContractMetrics(user?.id, contract.id)
const dayMetrics = metrics && metrics.from && metrics.from.day const dayMetrics = metrics && metrics.from && metrics.from.day
const outcome = const binaryOutcome =
metrics && metrics.hasShares && metrics.totalShares.YES ? 'YES' : 'NO' metrics && floatingEqual(metrics.totalShares.NO ?? 0, 0) ? 'YES' : 'NO'
const displayedProfit = dayMetrics
? ENV_CONFIG.moneyMoniker +
(dayMetrics.profit > 0 ? '+' : '') +
dayMetrics.profit.toFixed(0)
: undefined
return ( return (
<Card className={clsx(className, 'mb-4')}> <Card className={clsx(className, 'mb-4')}>
@ -421,27 +433,19 @@ export function ContractCardProbChange(props: {
> >
<span className="line-clamp-3">{contract.question}</span> <span className="line-clamp-3">{contract.question}</span>
</SiteLink> </SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} /> <ProbOrNumericChange className="py-2 pr-4" contract={contract} />
</Row> </Row>
{showPosition && metrics && ( {showPosition && metrics && metrics.hasShares && (
<Row <Row
className={clsx( className={clsx(
'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm' 'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm'
)} )}
> >
<Row className="gap-1 text-gray-700"> <Row className="gap-1 text-gray-400">
<div className="text-gray-500">Position</div> You: {formatWithCommas(metrics.totalShares[binaryOutcome])}{' '}
{formatMoney(metrics.payout)} {outcome} {binaryOutcome === 'YES' ? yesOutcomeLabel : noOutcomeLabel} shares
<span className="ml-1.5">{displayedProfit} today</span>
</Row> </Row>
{dayMetrics && (
<>
<Row className="items-center">
<div className="mr-1 text-gray-500">Daily profit</div>
<ProfitBadgeMana amount={dayMetrics.profit} gray />
</Row>
</>
)}
</Row> </Row>
)} )}
</Card> </Card>

View File

@ -45,13 +45,11 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
const { contract, isAdmin } = props const { contract, isAdmin } = props
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editingQ, setEditingQ] = useState(false) const [editingQ, setEditingQ] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
// key: `description ${contract.id}`, // key: `description ${contract.id}`,
max: MAX_DESCRIPTION_LENGTH, max: MAX_DESCRIPTION_LENGTH,
defaultValue: contract.description, defaultValue: contract.description,
disabled: isSubmitting,
}) })
async function saveDescription() { async function saveDescription() {
@ -66,10 +64,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
<Row className="gap-2"> <Row className="gap-2">
<Button <Button
onClick={async () => { onClick={async () => {
setIsSubmitting(true)
await saveDescription() await saveDescription()
setEditing(false) setEditing(false)
setIsSubmitting(false)
}} }}
> >
Save Save

View File

@ -34,6 +34,7 @@ import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle' import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
import { useIsClient } from 'web/hooks/use-is-client'
import { import {
BountiedContractBadge, BountiedContractBadge,
BountiedContractSmallBadge, BountiedContractSmallBadge,
@ -52,22 +53,23 @@ export function MiscDetails(props: {
const { volume, closeTime, isResolved, createdTime, resolutionTime } = const { volume, closeTime, isResolved, createdTime, resolutionTime } =
contract contract
const isClient = useIsClient()
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const groupToDisplay = getGroupLinkToDisplay(contract) const groupToDisplay = getGroupLinkToDisplay(contract)
return ( return (
<Row className="items-center gap-3 truncate text-sm text-gray-400"> <Row className="items-center gap-3 truncate text-sm text-gray-400">
{showTime === 'close-date' ? ( {isClient && showTime === 'close-date' ? (
<Row className="gap-0.5 whitespace-nowrap"> <Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)} {fromNow(closeTime || 0)}
</Row> </Row>
) : showTime === 'resolve-date' && resolutionTime !== undefined ? ( ) : isClient && showTime === 'resolve-date' && resolutionTime ? (
<Row className="gap-0.5"> <Row className="gap-0.5">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
{'Resolved '} {'Resolved '}
{fromNow(resolutionTime || 0)} {fromNow(resolutionTime)}
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
@ -390,6 +392,7 @@ function EditableCloseDate(props: {
}) { }) {
const { closeTime, contract, isCreator, disabled } = props const { closeTime, contract, isCreator, disabled } = props
const isClient = useIsClient()
const dayJsCloseTime = dayjs(closeTime) const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs() const dayJsNow = dayjs()
@ -452,7 +455,7 @@ function EditableCloseDate(props: {
className="w-full shrink-0 sm:w-fit" className="w-full shrink-0 sm:w-fit"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)} onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()} min={isClient ? Date.now() : undefined}
value={closeDate} value={closeDate}
/> />
<Input <Input
@ -479,14 +482,18 @@ function EditableCloseDate(props: {
</Col> </Col>
</Modal> </Modal>
<DateTimeTooltip <DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} text={
isClient && closeTime <= Date.now()
? 'Trading ended:'
: 'Trading ends:'
}
time={closeTime} time={closeTime}
> >
<Row <Row
className={clsx(!disabled && isCreator ? 'cursor-pointer' : '')} className={clsx(!disabled && isCreator ? 'cursor-pointer' : '')}
onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)} onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
> >
{isSameDay ? ( {isSameDay && isClient ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span> <span className={'capitalize'}> {fromNow(closeTime)}</span>
) : isSameYear ? ( ) : isSameYear ? (
dayJsCloseTime.format('MMM D') dayJsCloseTime.format('MMM D')

View File

@ -19,11 +19,10 @@ import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../duplicate-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 { IconButton } from '../button'
import { AddLiquidityButton } from './add-liquidity-button' import { AddLiquidityButton } from './add-liquidity-button'
import { Tooltip } from '../tooltip'
export const contractDetailsButtonClassName = import { Table } from '../table'
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { export function ContractInfoDialog(props: {
contract: Contract contract: Contract
@ -84,171 +83,173 @@ export function ContractInfoDialog(props: {
return ( return (
<> <>
<Button <Tooltip text="Market details" placement="bottom" noTap noFade>
size="sm" <IconButton
color="gray-white" size="2xs"
className={clsx(contractDetailsButtonClassName, className)} className={clsx(className)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<DotsHorizontalIcon <DotsHorizontalIcon
className={clsx('h-5 w-5 flex-shrink-0')} className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true" aria-hidden="true"
/> />
</Button> </IconButton>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<Col className="gap-4 rounded bg-white p-6"> <Col className="gap-4 rounded bg-white p-6">
<Title className="!mt-0 !mb-0" text="This Market" /> <Title className="!mt-0 !mb-0" text="This Market" />
<table className="table-compact table-zebra table w-full text-gray-500"> <Table>
<tbody> <tbody>
<tr>
<td>Type</td>
<td>{typeDisplay}</td>
</tr>
<tr>
<td>Payout</td>
<td className="flex gap-1">
{mechanism === 'cpmm-1' ? (
<>
Fixed{' '}
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
</>
) : (
<>
Parimutuel{' '}
<InfoTooltip text="Each share is a fraction of the pool. " />
</>
)}
</td>
</tr>
<tr>
<td>Market created</td>
<td>{formatTime(createdTime)}</td>
</tr>
{closeTime && (
<tr> <tr>
<td>Market close{closeTime > Date.now() ? 's' : 'd'}</td> <td>Type</td>
<td>{formatTime(closeTime)}</td> <td>{typeDisplay}</td>
</tr> </tr>
)}
{resolutionTime && (
<tr> <tr>
<td>Market resolved</td> <td>Payout</td>
<td>{formatTime(resolutionTime)}</td> <td className="flex gap-1">
</tr> {mechanism === 'cpmm-1' ? (
)} <>
Fixed{' '}
<tr> <InfoTooltip text="Each YES share is worth M$1 if YES wins." />
<td> </>
<span className="mr-1">Volume</span> ) : (
<InfoTooltip text="Total amount bought or sold" /> <>
</td> Parimutuel{' '}
<td>{formatMoney(contract.volume)}</td> <InfoTooltip text="Each share is a fraction of the pool. " />
</tr> </>
)}
<tr>
<td>{capitalize(BETTORS)}</td>
<td>{uniqueBettorCount ?? '0'}</td>
</tr>
<tr>
<td>
<Row>
<span className="mr-1">Elasticity</span>
<InfoTooltip
text={
mechanism === 'cpmm-1'
? 'Probability change between a M$50 bet on YES and NO'
: 'Probability change from a M$100 bet'
}
/>
</Row>
</td>
<td>{formatPercent(elasticity)}</td>
</tr>
<tr>
<td>Liquidity subsidies</td>
<td>
{mechanism === 'cpmm-1'
? formatMoney(contract.totalLiquidity)
: formatMoney(100)}
</td>
</tr>
<tr>
<td>Pool</td>
<td>
{mechanism === 'cpmm-1' && outcomeType === 'BINARY'
? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO`
: mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC'
? `${Math.round(pool.YES)} HIGHER, ${Math.round(
pool.NO
)} LOWER`
: contractPool(contract)}
</td>
</tr>
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && (
<tr>
<td>[ADMIN] Firestore</td>
<td>
<SiteLink href={firestoreConsolePath(id)}>
Console link
</SiteLink>
</td> </td>
</tr> </tr>
)}
{isAdmin && (
<tr>
<td>[ADMIN] Featured</td>
<td>
<ShortToggle
on={featured}
setOn={setFeatured}
onChange={onFeaturedToggle}
/>
</td>
</tr>
)}
{user && (
<tr>
<td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td>
<td>
<ShortToggle
disabled={
isUnlisted
? !(isAdmin || (isCreator && wasUnlistedByCreator))
: !(isCreator || isAdmin)
}
on={contract.visibility === 'unlisted'}
setOn={(b) =>
updateContract(id, {
visibility: b ? 'unlisted' : 'public',
unlistedById: b ? user.id : '',
})
}
/>
</td>
</tr>
)}
</tbody>
</table>
<Row className="flex-wrap"> <tr>
{mechanism === 'cpmm-1' && ( <td>Market created</td>
<AddLiquidityButton contract={contract} className="mr-2" /> <td>{formatTime(createdTime)}</td>
)} </tr>
<DuplicateContractButton contract={contract} />
</Row> {closeTime && (
</Col> <tr>
</Modal> <td>Market close{closeTime > Date.now() ? 's' : 'd'}</td>
<td>{formatTime(closeTime)}</td>
</tr>
)}
{resolutionTime && (
<tr>
<td>Market resolved</td>
<td>{formatTime(resolutionTime)}</td>
</tr>
)}
<tr>
<td>
<span className="mr-1">Volume</span>
<InfoTooltip text="Total amount bought or sold" />
</td>
<td>{formatMoney(contract.volume)}</td>
</tr>
<tr>
<td>{capitalize(BETTORS)}</td>
<td>{uniqueBettorCount ?? '0'}</td>
</tr>
<tr>
<td>
<Row>
<span className="mr-1">Elasticity</span>
<InfoTooltip
text={
mechanism === 'cpmm-1'
? 'Probability change between a M$50 bet on YES and NO'
: 'Probability change from a M$100 bet'
}
/>
</Row>
</td>
<td>{formatPercent(elasticity)}</td>
</tr>
<tr>
<td>Liquidity subsidies</td>
<td>
{mechanism === 'cpmm-1'
? formatMoney(contract.totalLiquidity)
: formatMoney(100)}
</td>
</tr>
<tr>
<td>Pool</td>
<td>
{mechanism === 'cpmm-1' && outcomeType === 'BINARY'
? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO`
: mechanism === 'cpmm-1' &&
outcomeType === 'PSEUDO_NUMERIC'
? `${Math.round(pool.YES)} HIGHER, ${Math.round(
pool.NO
)} LOWER`
: contractPool(contract)}
</td>
</tr>
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && (
<tr>
<td>[ADMIN] Firestore</td>
<td>
<SiteLink href={firestoreConsolePath(id)}>
Console link
</SiteLink>
</td>
</tr>
)}
{isAdmin && (
<tr>
<td>[ADMIN] Featured</td>
<td>
<ShortToggle
on={featured}
setOn={setFeatured}
onChange={onFeaturedToggle}
/>
</td>
</tr>
)}
{user && (
<tr>
<td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td>
<td>
<ShortToggle
disabled={
isUnlisted
? !(isAdmin || (isCreator && wasUnlistedByCreator))
: !(isCreator || isAdmin)
}
on={contract.visibility === 'unlisted'}
setOn={(b) =>
updateContract(id, {
visibility: b ? 'unlisted' : 'public',
unlistedById: b ? user.id : '',
})
}
/>
</td>
</tr>
)}
</tbody>
</Table>
<Row className="flex-wrap">
{mechanism === 'cpmm-1' && (
<AddLiquidityButton contract={contract} className="mr-2" />
)}
<DuplicateContractButton contract={contract} />
</Row>
</Col>
</Modal>
</Tooltip>
</> </>
) )
} }

View File

@ -6,17 +6,19 @@ import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { BinaryContractOutcomeLabel } from '../outcome-label' import { BinaryContractOutcomeLabel } from '../outcome-label'
import { getColor } from './quick-bet' import { getColor } from './quick-bet'
import { useIsClient } from 'web/hooks/use-is-client'
export function ContractMention(props: { contract: Contract }) { export function ContractMention(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const { outcomeType, resolution } = contract const { outcomeType, resolution } = contract
const probTextColor = `text-${getColor(contract)}` const probTextColor = `text-${getColor(contract)}`
const isClient = useIsClient()
return ( return (
<Link href={contractPath(contract)}> <Link href={contractPath(contract)}>
<a <a
className="group inline whitespace-nowrap rounded-sm hover:bg-indigo-50 focus:bg-indigo-50" className="group inline whitespace-nowrap rounded-sm hover:bg-indigo-50 focus:bg-indigo-50"
title={tooltipLabel(contract)} title={isClient ? tooltipLabel(contract) : undefined}
> >
<span className="break-anywhere mr-0.5 whitespace-normal font-normal text-indigo-700"> <span className="break-anywhere mr-0.5 whitespace-normal font-normal text-indigo-700">
{contract.question} {contract.question}

View File

@ -32,7 +32,7 @@ export function ContractReportResolution(props: { contract: Contract }) {
} }
const flagClass = clsx( const flagClass = clsx(
'mx-2 flex flex-col items-center gap-1 w-6 h-6 rounded-md !bg-gray-100 px-2 py-1 hover:bg-gray-300', 'mx-2 flex flex-col items-center gap-1 w-6 h-6 rounded-md !bg-gray-100 px-1 py-2 hover:bg-gray-300',
userReported ? '!text-red-500' : '!text-gray-500' userReported ? '!text-red-500' : '!text-gray-500'
) )

View File

@ -1,9 +1,8 @@
import { memo, useState } from 'react' import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets' import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { CommentsAnswer } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy, sum } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -25,7 +24,6 @@ import {
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip' import { Tooltip } from 'web/components/tooltip'
@ -36,14 +34,27 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import Curve from 'web/public/custom-components/curve'
import { Answer } from 'common/answer'
import { AnswerCommentInput } from '../comment-input'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
userBets: Bet[] userBets: Bet[]
comments: ContractComment[] comments: ContractComment[]
answerResponse?: Answer | undefined
onCancelAnswerResponse?: () => void
}) { }) {
const { contract, bets, userBets, comments } = props const {
contract,
bets,
userBets,
comments,
answerResponse,
onCancelAnswerResponse,
} = props
const yourTrades = ( const yourTrades = (
<div> <div>
@ -56,7 +67,14 @@ export function ContractTabs(props: {
const tabs = buildArray( const tabs = buildArray(
{ {
title: 'Comments', title: 'Comments',
content: <CommentsTabContent contract={contract} comments={comments} />, content: (
<CommentsTabContent
contract={contract}
comments={comments}
answerResponse={answerResponse}
onCancelAnswerResponse={onCancelAnswerResponse}
/>
),
}, },
bets.length > 0 && { bets.length > 0 && {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
@ -76,8 +94,10 @@ export function ContractTabs(props: {
const CommentsTabContent = memo(function CommentsTabContent(props: { const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract contract: Contract
comments: ContractComment[] comments: ContractComment[]
answerResponse?: Answer
onCancelAnswerResponse?: () => void
}) { }) {
const { contract } = props const { contract, answerResponse, onCancelAnswerResponse } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', { const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
@ -95,10 +115,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
// replied to answers/comments are NOT newest, otherwise newest first // replied to answers/comments are NOT newest, otherwise newest first
const shouldBeNewestFirst = (c: ContractComment) => const shouldBeNewestFirst = (c: ContractComment) =>
c.replyToCommentId == undefined && c.replyToCommentId == undefined
(contract.outcomeType === 'FREE_RESPONSE'
? c.betId === undefined && c.answerOutcome == undefined
: true)
// TODO: links to comments are broken because tips load after render and // TODO: links to comments are broken because tips load after render and
// comments will reorganize themselves if there are tips/bounties awarded // comments will reorganize themselves if there are tips/bounties awarded
@ -123,73 +140,85 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const topLevelComments = commentsByParent['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
const sortRow = comments.length > 0 && ( const sortRow = comments.length > 0 && (
<Row className="mb-4 items-center"> <Row className="mb-4 items-center justify-end gap-4">
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={
sort === 'Best'
? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: ''
}
>
Sort by: {sort}
</Tooltip>
</Button>
<BountiedContractSmallBadge contract={contract} showAmount /> <BountiedContractSmallBadge contract={contract} showAmount />
<Row className="items-center gap-1">
<div className="text-greyscale-4 text-sm">Sort by:</div>
<button
className="text-greyscale-6 w-20 text-sm"
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={sort === 'Best' ? 'Highest tips + bounties first.' : ''}
>
<Row className="items-center gap-1">
{sort}
<TriangleDownFillIcon className=" h-2 w-2" />
</Row>
</Tooltip>
</button>
</Row>
</Row> </Row>
) )
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
sortedComments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
return ( return (
<> <>
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow} {sortRow}
{sortedAnswers.map((answer) => ( {answerResponse && (
<div key={answer.id} className="relative pb-4"> <AnswerCommentInput
<span contract={contract}
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" answerResponse={answerResponse}
aria-hidden="true" onCancelAnswerResponse={onCancelAnswerResponse}
/> />
<FeedAnswerCommentGroup )}
contract={contract} {topLevelComments.map((parent) => {
answer={answer} if (parent.answerOutcome === undefined) {
answerComments={commentsByOutcome[answer.number.toString()] ?? []} return (
tips={tips} <FeedCommentThread
/> key={parent.id}
</div> contract={contract}
))} parentComment={parent}
<Col className="mt-8 flex w-full"> threadComments={sortBy(
<div className="text-md mt-8 mb-2 text-left">General Comments</div> commentsByParent[parent.id] ?? [],
<div className="mb-4 w-full border-b border-gray-200" /> (c) => c.createdTime
<ContractCommentInput className="mb-5" contract={contract} /> )}
{sortRow} tips={tips}
/>
{generalTopLevelComments.map((comment) => ( )
<FeedCommentThread }
key={comment.id} const answer = contract.answers.find(
contract={contract} (answer) => answer.id === parent.answerOutcome
parentComment={comment} )
threadComments={commentsByParent[comment.id] ?? []} if (answer === undefined) {
tips={tips} console.error('Could not find answer that matches ID')
/> return <></>
))} }
</Col> return (
<>
<Row className="gap-2">
<CommentsAnswer answer={answer} contract={contract} />
</Row>
<Row>
<div className="ml-1">
<Curve size={28} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="w-full pt-1">
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
</Row>
</>
)
})}
</> </>
) )
} else { } else {

View File

@ -2,7 +2,7 @@ import { ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button } from 'web/components/button' import { IconButton } from 'web/components/button'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { ShareModal } from './share-modal' import { ShareModal } from './share-modal'
import { FollowMarketButton } from 'web/components/follow-market-button' import { FollowMarketButton } from 'web/components/follow-market-button'
@ -16,15 +16,14 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
return ( return (
<Row> <Row className="gap-1">
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
<LikeMarketButton contract={contract} user={user} /> <LikeMarketButton contract={contract} user={user} />
<Tooltip text="Share" placement="bottom" noTap noFade> <Tooltip text="Share" placement="bottom" noTap noFade>
<Button <IconButton
size="sm" size="2xs"
color="gray-white"
className={'flex'} className={'flex'}
onClick={() => setShareOpen(true)} onClick={() => setShareOpen(true)}
> >
@ -35,7 +34,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
contract={contract} contract={contract}
user={user} user={user}
/> />
</Button> </IconButton>
</Tooltip> </Tooltip>
<ContractInfoDialog contract={contract} user={user} /> <ContractInfoDialog contract={contract} user={user} />

View File

@ -7,12 +7,14 @@ import { formatPercent } from 'common/util/format'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { ContractCardProbChange } from './contract-card' import { ContractCardProbChange } from './contract-card'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function ProfitChangeTable(props: { export function ProfitChangeTable(props: {
contracts: CPMMBinaryContract[] contracts: CPMMBinaryContract[]
metrics: ContractMetrics[] metrics: ContractMetrics[]
maxRows?: number
}) { }) {
const { contracts, metrics } = props const { contracts, metrics, maxRows } = props
const contractProfit = metrics.map( const contractProfit = metrics.map(
(m) => [m.contractId, m.from?.day.profit ?? 0] as const (m) => [m.contractId, m.from?.day.profit ?? 0] as const
@ -26,7 +28,7 @@ export function ProfitChangeTable(props: {
positiveProfit.map(([contractId]) => positiveProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId) contracts.find((c) => c.id === contractId)
) )
) ).slice(0, maxRows)
const negativeProfit = sortBy( const negativeProfit = sortBy(
contractProfit.filter(([, profit]) => profit < 0), contractProfit.filter(([, profit]) => profit < 0),
@ -36,7 +38,7 @@ export function ProfitChangeTable(props: {
negativeProfit.map(([contractId]) => negativeProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId) contracts.find((c) => c.id === contractId)
) )
) ).slice(0, maxRows)
if (positive.length === 0 && negative.length === 0) if (positive.length === 0 && negative.length === 0)
return <div className="px-4 text-gray-500">None</div> return <div className="px-4 text-gray-500">None</div>
@ -118,7 +120,7 @@ export function ProbChangeTable(props: {
) )
} }
export function ProbChange(props: { export function ProbOrNumericChange(props: {
contract: CPMMContract contract: CPMMContract
className?: string className?: string
}) { }) {
@ -127,13 +129,17 @@ export function ProbChange(props: {
prob, prob,
probChanges: { day: change }, probChanges: { day: change },
} = contract } = contract
const number =
contract.outcomeType === 'PSEUDO_NUMERIC'
? formatNumericProbability(prob, contract)
: null
const color = change >= 0 ? 'text-green-500' : 'text-red-500' const color = change >= 0 ? 'text-teal-500' : 'text-red-400'
return ( return (
<Col className={clsx('flex flex-col items-end', className)}> <Col className={clsx('flex flex-col items-end', className)}>
<div className="mb-0.5 mr-0.5 text-2xl"> <div className="mb-0.5 mr-0.5 text-2xl">
{formatPercent(Math.round(100 * prob) / 100)} {number ? number : formatPercent(Math.round(100 * prob) / 100)}
</div> </div>
<div className={clsx('text-base', color)}> <div className={clsx('text-base', color)}>
{(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'} {(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'}

View File

@ -166,14 +166,14 @@ export function QuickBet(props: {
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-400' upHover ? 'text-teal-500' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-200' upHover ? 'text-teal-500' : 'text-gray-200'
)} )}
/> />
)} )}
@ -201,14 +201,14 @@ export function QuickBet(props: {
<TriangleDownFillIcon <TriangleDownFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-400' downHover ? 'text-red-400' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleDownFillIcon <TriangleDownFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-200' downHover ? 'text-red-400' : 'text-gray-200'
)} )}
/> />
)} )}

View File

@ -1,10 +1,9 @@
import clsx from 'clsx' import clsx from 'clsx'
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney, shortFormatNumber } from 'common/util/format' import { formatMoney, shortFormatNumber } from 'common/util/format'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip' import { Tooltip } from '../tooltip'
import TipJar from 'web/public/custom-components/tipJar'
import { useState } from 'react'
export function TipButton(props: { export function TipButton(props: {
tipAmount: number tipAmount: number
@ -14,11 +13,12 @@ export function TipButton(props: {
isCompact?: boolean isCompact?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } = const { tipAmount, totalTipped, userTipped, onClick, disabled } = props
props
const tipDisplay = shortFormatNumber(Math.ceil(totalTipped / 10)) const tipDisplay = shortFormatNumber(Math.ceil(totalTipped / 10))
const [hover, setHover] = useState(false)
return ( return (
<Tooltip <Tooltip
text={ text={
@ -30,39 +30,39 @@ export function TipButton(props: {
noTap noTap
noFade noFade
> >
<Button <button
size={'sm'}
className={clsx(
'max-w-xs self-center',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={clsx(
'px-2 py-1 text-xs', //2xs button
'text-greyscale-6 transition-transform hover:text-indigo-600 disabled:cursor-not-allowed',
!disabled ? 'hover:rotate-12' : ''
)}
onMouseOver={() => setHover(true)}
onMouseLeave={() => setHover(false)}
> >
<Col className={'relative items-center sm:flex-row'}> <Col className={clsx('relative', disabled ? 'opacity-30' : '')}>
<HeartIcon <TipJar
className={clsx( size={18}
'h-5 w-5 sm:h-6 sm:w-6', color={userTipped || (hover && !disabled) ? '#4f46e5' : '#66667C'}
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : ''
)}
/> />
{totalTipped > 0 && ( <div
<div className={clsx(
className={clsx( userTipped && 'text-indigo-600',
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', ' absolute top-[2px] text-[0.5rem]',
tipDisplay.length > 2 tipDisplay.length === 1
? 'text-[0.4rem] sm:text-[0.5rem]' ? 'left-[7px]'
: 'sm:text-2xs text-[0.5rem]' : tipDisplay.length === 2
)} ? 'left-[4.5px]'
> : tipDisplay.length > 2
{tipDisplay} ? 'left-[4px] top-[2.5px] text-[0.35rem]'
</div> : ''
)} )}
>
{totalTipped > 0 ? tipDisplay : ''}
</div>
</Col> </Col>
</Button> </button>
</Tooltip> </Tooltip>
) )
} }

View File

@ -4,12 +4,12 @@ import { Title } from 'web/components/title'
import { TextEditor, useTextEditor } from 'web/components/editor' import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api' import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router' import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post' import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts' import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group' import { Group } from 'common/group'
import { ExpandingInput } from './expanding-input' import { ExpandingInput } from './expanding-input'
import { Button } from './button'
export function CreatePost(props: { group?: Group }) { export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@ -22,7 +22,6 @@ export function CreatePost(props: { group?: Group }) {
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
key: `post ${group?.id || ''}`, key: `post ${group?.id || ''}`,
disabled: isSubmitting,
}) })
const isValid = const isValid =
@ -56,8 +55,8 @@ export function CreatePost(props: { group?: Group }) {
<div className="rounded-lg px-6 py-4 sm:py-0"> <div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" /> <Title className="!mt-0" text="Create a post" />
<form> <form>
<div className="form-control w-full"> <div className="flex w-full flex-col">
<label className="label"> <label className="px-1 py-2">
<span className="mb-1"> <span className="mb-1">
Title<span className={'text-red-700'}> *</span> Title<span className={'text-red-700'}> *</span>
</span> </span>
@ -70,7 +69,7 @@ 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"> <label className="px-1 py-2">
<span className="mb-1"> <span className="mb-1">
Subtitle<span className={'text-red-700'}> *</span> Subtitle<span className={'text-red-700'}> *</span>
</span> </span>
@ -83,7 +82,7 @@ export function CreatePost(props: { group?: Group }) {
onChange={(e) => setSubtitle(e.target.value || '')} onChange={(e) => setSubtitle(e.target.value || '')}
/> />
<Spacer h={6} /> <Spacer h={6} />
<label className="label"> <label className="px-1 py-2">
<span className="mb-1"> <span className="mb-1">
Content<span className={'text-red-700'}> *</span> Content<span className={'text-red-700'}> *</span>
</span> </span>
@ -91,13 +90,12 @@ export function CreatePost(props: { group?: Group }) {
<TextEditor editor={editor} upload={upload} /> <TextEditor editor={editor} upload={upload} />
<Spacer h={6} /> <Spacer h={6} />
<button <Button
type="submit" type="submit"
className={clsx( color="green"
'btn btn-primary normal-case', size="xl"
isSubmitting && 'loading disabled' loading={isSubmitting}
)} disabled={!isValid || upload.isLoading}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => { onClick={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await savePost(title) await savePost(title)
@ -105,7 +103,7 @@ export function CreatePost(props: { group?: Group }) {
}} }}
> >
{isSubmitting ? 'Creating...' : 'Create a post'} {isSubmitting ? 'Creating...' : 'Create a post'}
</button> </Button>
{error !== '' && <div className="text-red-700">{error}</div>} {error !== '' && <div className="text-red-700">{error}</div>}
</div> </div>
</form> </form>

View File

@ -18,10 +18,11 @@ import { useCallback, useEffect, useState } from 'react'
import { Linkify } from './linkify' import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage' import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query' import { useMutation } from 'react-query'
import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import { DisplayContractMention } from './editor/contract-mention' import { DisplayContractMention } from './editor/contract-mention'
import GridComponent from './editor/tiptap-grid-cards'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet' import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal' import { EmbedModal } from './editor/embed-modal'
@ -41,6 +42,7 @@ import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon' import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse' import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler' import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
import { ImageModal } from './editor/image-modal'
import { import {
storageStore, storageStore,
usePersistentState, usePersistentState,
@ -50,7 +52,7 @@ import { debounce } from 'lodash'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
class: 'max-h-60', class: 'max-h-60 hover:max-h-[120rem] transition-all',
}, },
}) })
@ -78,6 +80,7 @@ export const editorExtensions = (simple = false): Extensions => [
DisplayLink, DisplayLink,
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({
@ -87,18 +90,17 @@ export const editorExtensions = (simple = false): Extensions => [
const proseClass = clsx( const proseClass = clsx(
'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed', 'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
'font-light prose-a:font-light prose-blockquote:font-light' 'font-light prose-a:font-light prose-blockquote:font-light prose-sm'
) )
export function useTextEditor(props: { export function useTextEditor(props: {
placeholder?: string placeholder?: string
max?: number max?: number
defaultValue?: Content defaultValue?: Content
disabled?: boolean
simple?: boolean simple?: boolean
key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave
}) { }) {
const { placeholder, max, defaultValue, disabled, simple, key } = props const { placeholder, max, defaultValue, simple, key } = props
const [content, saveContent] = usePersistentState<JSONContent | undefined>( const [content, saveContent] = usePersistentState<JSONContent | undefined>(
undefined, undefined,
@ -166,10 +168,6 @@ export function useTextEditor(props: {
}, },
}) })
useEffect(() => {
editor?.setEditable(!disabled)
}, [editor, disabled])
return { editor, upload } return { editor, upload }
} }
@ -252,6 +250,7 @@ export function TextEditor(props: {
children?: React.ReactNode // additional toolbar buttons children?: React.ReactNode // additional toolbar buttons
}) { }) {
const { editor, upload, children } = props const { editor, upload, children } = props
const [imageOpen, setImageOpen] = useState(false)
const [iframeOpen, setIframeOpen] = useState(false) const [iframeOpen, setIframeOpen] = useState(false)
const [marketOpen, setMarketOpen] = useState(false) const [marketOpen, setMarketOpen] = useState(false)
@ -259,18 +258,26 @@ export function TextEditor(props: {
<> <>
{/* hide placeholder when focused */} {/* hide placeholder when focused */}
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> {/* matches input styling */}
<div className="rounded-lg border border-gray-300 bg-white shadow-sm transition-colors focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<FloatingMenu editor={editor} /> <FloatingMenu editor={editor} />
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */} {/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex h-9 items-center gap-5 pl-4 pr-1">
<Tooltip text="Add image" noTap noFade> <Tooltip text="Add image" noTap noFade>
<FileUploadButton <button
onFiles={upload.mutate} type="button"
onClick={() => setImageOpen(true)}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
> >
<ImageModal
editor={editor}
upload={upload}
open={imageOpen}
setOpen={setImageOpen}
/>
<PhotographIcon className="h-5 w-5" aria-hidden="true" /> <PhotographIcon className="h-5 w-5" aria-hidden="true" />
</FileUploadButton> </button>
</Tooltip> </Tooltip>
<Tooltip text="Add embed" noTap noFade> <Tooltip text="Add embed" noTap noFade>
<button <button
@ -355,6 +362,7 @@ export function RichContent(props: {
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({

View File

@ -0,0 +1,160 @@
import { UploadIcon } from '@heroicons/react/outline'
import { Editor } from '@tiptap/react'
import { useState } from 'react'
import { AlertBox } from '../alert-box'
import { Button } from '../button'
import { FileUploadButton } from '../file-upload-button'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Tabs } from '../layout/tabs'
const MODIFIERS =
'8k, beautiful, illustration, trending on art station, picture of the day, epic composition'
export function ImageModal(props: {
editor: Editor | null
// TODO: Type this correctly?
upload: any
open: boolean
setOpen: (open: boolean) => void
}) {
const { upload, open, setOpen } = props
return (
<Modal open={open} setOpen={setOpen}>
<Col className="gap-2 rounded bg-white p-6">
<Tabs
tabs={[
{
title: 'Upload file',
content: (
<FileUploadButton
onFiles={(files) => {
setOpen(false)
upload.mutate(files)
}}
className="relative block w-full rounded-lg border-2 border-dashed border-gray-300 p-12 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<UploadIcon className="mx-auto h-12 w-12 text-gray-400" />
<span className="mt-2 block text-sm font-medium text-gray-400">
Upload an image file
</span>
</FileUploadButton>
),
},
{
title: 'Dream',
content: <DreamTab {...props} />,
},
]}
/>
</Col>
</Modal>
)
}
// Note: this is currently tied to a DreamStudio API key tied to akrolsmir@gmail.com,
// and injected on Vercel.
const API_KEY = process.env.NEXT_PUBLIC_DREAM_KEY
function DreamTab(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, setOpen } = props
const [input, setInput] = useState('')
const [isDreaming, setIsDreaming] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const imageCode = `<img src="${imageUrl}" alt="${input}" />`
if (!API_KEY) {
return (
<AlertBox
title="Missing API Key"
text="An API key from https://beta.dreamstudio.ai/ is needed to dream; add it to your web/.env.local"
/>
)
}
async function dream() {
setIsDreaming(true)
const data = {
prompt: input + ', ' + MODIFIERS,
apiKey: API_KEY,
}
const response = await fetch(`/api/v0/dream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const json = await response.json()
setImageUrl(json.url)
setIsDreaming(false)
}
return (
<Col className="gap-2">
<Row className="gap-2">
<input
autoFocus
type="text"
name="embed"
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm placeholder:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="A crane playing poker on a green table"
value={input}
onChange={(e) => setInput(e.target.value)}
autoComplete="off"
/>
<Button
className="whitespace-nowrap"
onClick={dream}
loading={isDreaming}
>
Dream
{/* TODO: Charge M$5 with ({formatMoney(5)}) */}
</Button>
</Row>
{isDreaming && (
<div className="text-sm">This may take ~10 seconds...</div>
)}
{/* TODO: Allow the user to choose their own modifiers */}
<div className="pt-2 text-sm text-gray-400">
Commission a custom image using AI.
</div>
<div className="pt-2 text-xs text-gray-400">Modifiers: {MODIFIERS}</div>
{/* Show the current imageUrl */}
{/* TODO: Keep the other generated images, so the user can play with different attempts. */}
{imageUrl && (
<>
<img src={imageUrl} alt="Image" />
<Row className="gap-2">
<Button
disabled={isDreaming}
onClick={() => {
if (editor) {
editor.chain().insertContent(imageCode).run()
setInput('')
setOpen(false)
}
}}
>
Add image
</Button>
<Button
color="gray"
onClick={() => {
setInput('')
setOpen(false)
}}
>
Cancel
</Button>
</Row>
</>
)}
</Col>
)
}

View File

@ -1,7 +1,7 @@
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { SelectMarketsModal } from '../contract-select-modal' import { SelectMarketsModal } from '../contract-select-modal'
import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { embedContractCode } from '../share-embed-button'
import { insertContent } from './utils' import { insertContent } from './utils'
export function MarketModal(props: { export function MarketModal(props: {
@ -15,7 +15,10 @@ export function MarketModal(props: {
if (contracts.length == 1) { if (contracts.length == 1) {
insertContent(editor, embedContractCode(contracts[0])) insertContent(editor, embedContractCode(contracts[0]))
} else if (contracts.length > 1) { } else if (contracts.length > 1) {
insertContent(editor, embedContractGridCode(contracts)) insertContent(
editor,
`<grid-cards-component contractIds="${contracts.map((c) => c.id)}" />`
)
} }
} }

View File

@ -0,0 +1,55 @@
import { mergeAttributes, Node } from '@tiptap/core'
import React from 'react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { ContractsGrid } from '../contract/contracts-grid'
import { useContractsFromIds } from 'web/hooks/use-contract'
import { LoadingIndicator } from '../loading-indicator'
export default Node.create({
name: 'gridCardsComponent',
group: 'block',
atom: true,
addAttributes() {
return {
contractIds: [],
}
},
parseHTML() {
return [
{
tag: 'grid-cards-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['grid-cards-component', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(GridComponent)
},
})
export function GridComponent(props: any) {
const contractIds = props.node.attrs.contractIds
const contracts = useContractsFromIds(contractIds.split(','))
return (
<NodeViewWrapper className="grid-cards-component">
{contracts ? (
<ContractsGrid
contracts={contracts}
breakpointColumns={{ default: 2, 650: 1 }}
/>
) : (
<LoadingIndicator />
)}
</NodeViewWrapper>
)
}

View File

@ -7,7 +7,7 @@ export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => {
return ( return (
<Textarea <Textarea
className={clsx( className={clsx(
'textarea textarea-bordered resize-none text-[16px] md:text-[14px]', 'resize-none rounded-md border border-gray-300 bg-white px-4 text-[16px] leading-loose shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500 md:text-[14px]',
className className
)} )}
{...rest} {...rest}

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { LinkIcon } from '@heroicons/react/outline' import { LinkIcon } from '@heroicons/react/outline'
import { useIsClient } from 'web/hooks/use-is-client'
export function CopyLinkDateTimeComponent(props: { export function CopyLinkDateTimeComponent(props: {
prefix: string prefix: string
@ -14,6 +15,7 @@ export function CopyLinkDateTimeComponent(props: {
className?: string className?: string
}) { }) {
const { prefix, slug, elementId, createdTime, className } = props const { prefix, slug, elementId, createdTime, className } = props
const isClient = useIsClient()
const [showToast, setShowToast] = useState(false) const [showToast, setShowToast] = useState(false)
function copyLinkToComment( function copyLinkToComment(
@ -33,10 +35,10 @@ export function CopyLinkDateTimeComponent(props: {
<a <a
onClick={copyLinkToComment} onClick={copyLinkToComment}
className={ className={
'mx-1 whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100' 'text-greyscale-4 hover:bg-greyscale-1.5 mx-1 whitespace-nowrap rounded-sm px-1 text-xs transition-colors'
} }
> >
{fromNow(createdTime)} {isClient && fromNow(createdTime)}
{showToast && <ToastClipboard />} {showToast && <ToastClipboard />}
<LinkIcon className="ml-1 mb-0.5 inline" height={13} /> <LinkIcon className="ml-1 mb-0.5 inline" height={13} />
</a> </a>

View File

@ -1,46 +1,21 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractComment } from 'common/comment' import React, { useEffect, useRef } 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'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import {
ContractCommentInput,
FeedComment,
ReplyTo,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { 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 { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
export function FeedAnswerCommentGroup(props: { export function CommentsAnswer(props: { answer: Answer; contract: Contract }) {
contract: FreeResponseContract const { answer, contract } = props
answer: Answer
answerComments: ContractComment[]
tips: CommentTipMap
}) {
const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyTo, setReplyTo] = useState<ReplyTo>()
const user = useUser()
const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const router = useRouter()
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)
@ -48,83 +23,20 @@ export function FeedAnswerCommentGroup(props: {
}, [highlighted]) }, [highlighted])
return ( return (
<Col className="relative flex-1 items-stretch gap-3"> <Col className="bg-greyscale-2 w-fit gap-1 rounded-t-xl rounded-bl-xl py-2 px-4">
<Row <Row className="gap-2">
className={clsx( <Avatar username={username} avatarUrl={avatarUrl} size="xxs" />
'gap-3 space-x-3 pt-4 transition-all duration-1000', <div className="text-greyscale-6 text-xs">
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' <UserLink username={username} name={name} /> answered
)} <CopyLinkDateTimeComponent
ref={answerRef} prefix={contract.creatorUsername}
id={answerElementId} slug={contract.slug}
> createdTime={answer.createdTime}
<Avatar username={username} avatarUrl={avatarUrl} /> elementId={answerElementId}
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={answer.createdTime}
elementId={answerElementId}
/>
</div>
<Col className="align-items justify-between gap-2 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<div className="sm:hidden">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
</Col>
<div className="justify-initial hidden sm:block">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
</Col>
</Row>
<Col className="gap-3 pl-1">
{answerComments.map((comment) => (
<FeedComment
key={comment.id}
indent={true}
contract={contract}
comment={comment}
myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true}
onReplyClick={onReplyClick}
/>
))}
</Col>
{replyTo && (
<div className="relative ml-7">
<span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput
contract={contract}
parentAnswerOutcome={answer.number.toString()}
replyTo={replyTo}
onSubmitComment={onSubmitComment}
/> />
</div> </div>
)} </Row>
<div className="text-sm">{text}</div>
</Col> </Col>
) )
} }

View File

@ -23,6 +23,9 @@ import { Content } from '../editor'
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'
import { ReplyIcon } from '@heroicons/react/solid'
import { IconButton } from '../button'
import { ReplyToggle } from '../comments/reply-toggle'
export type ReplyTo = { id: string; username: string } export type ReplyTo = { id: string; username: string }
@ -34,6 +37,7 @@ 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 [seeReplies, setSeeReplies] = useState(true)
const user = useUser() const user = useUser()
const onSubmitComment = useEvent(() => setReplyTo(undefined)) const onSubmitComment = useEvent(() => setReplyTo(undefined))
@ -43,28 +47,37 @@ export function FeedCommentThread(props: {
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 <ParentFeedComment
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" key={parentComment.id}
aria-hidden="true" contract={contract}
comment={parentComment}
myTip={user ? tips[parentComment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[parentComment.id] ?? {}))}
showTip={true}
seeReplies={seeReplies}
numComments={threadComments.length}
onSeeReplyClick={() => setSeeReplies(!seeReplies)}
onReplyClick={() =>
setReplyTo({
id: parentComment.id,
username: parentComment.userUsername,
})
}
/> />
{[parentComment].concat(threadComments).map((comment, commentIdx) => ( {seeReplies &&
<FeedComment threadComments.map((comment, _commentIdx) => (
key={comment.id} <FeedComment
indent={commentIdx != 0} key={comment.id}
contract={contract} contract={contract}
comment={comment} comment={comment}
myTip={user ? tips[comment.id]?.[user.id] : undefined} myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))} totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true} showTip={true}
onReplyClick={onReplyClick} onReplyClick={onReplyClick}
/> />
))} ))}
{replyTo && ( {replyTo && (
<Col className="-pb-2 relative ml-6"> <Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
@ -77,38 +90,120 @@ export function FeedCommentThread(props: {
) )
} }
export function ParentFeedComment(props: {
contract: Contract
comment: ContractComment
showTip?: boolean
myTip?: number
totalTip?: number
seeReplies: boolean
numComments: number
onReplyClick?: (comment: ContractComment) => void
onSeeReplyClick: () => void
}) {
const {
contract,
comment,
myTip,
totalTip,
showTip,
onReplyClick,
onSeeReplyClick,
seeReplies,
numComments,
} = props
const { text, content, userUsername, userAvatarUrl } = comment
const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isReady && asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [isReady, asPath, comment.id])
useEffect(() => {
if (highlighted && commentRef.current) {
commentRef.current.scrollIntoView(true)
}
}, [highlighted])
return (
<Row
ref={commentRef}
id={comment.id}
className={clsx(
'hover:bg-greyscale-1 ml-3 gap-2 transition-colors',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
<Col className="-ml-3.5">
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
</Col>
<Col className="w-full">
<FeedCommentHeader comment={comment} contract={contract} />
<Content
className="text-greyscale-7 mt-2 grow text-[14px]"
content={content || text}
smallImage
/>
<Row className="justify-between">
<ReplyToggle
seeReplies={seeReplies}
numComments={numComments}
onClick={onSeeReplyClick}
/>
<CommentActions
onReplyClick={onReplyClick}
comment={comment}
showTip={showTip}
myTip={myTip}
totalTip={totalTip}
contract={contract}
/>
</Row>
</Col>
</Row>
)
}
export function CommentActions(props: {
onReplyClick?: (comment: ContractComment) => void
comment: ContractComment
showTip?: boolean
myTip?: number
totalTip?: number
contract: Contract
}) {
const { onReplyClick, comment, showTip, myTip, totalTip, contract } = props
return (
<Row className="grow justify-end">
{onReplyClick && (
<IconButton size={'xs'} onClick={() => onReplyClick(comment)}>
<ReplyIcon className="h-5 w-5" />
</IconButton>
)}
{showTip && (
<Tipper comment={comment} myTip={myTip ?? 0} totalTip={totalTip ?? 0} />
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
)
}
export const FeedComment = memo(function FeedComment(props: { export const FeedComment = memo(function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
showTip?: boolean showTip?: boolean
myTip?: number myTip?: number
totalTip?: number totalTip?: number
indent?: boolean
onReplyClick?: (comment: ContractComment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { contract, comment, myTip, totalTip, showTip, indent, onReplyClick } = const { contract, comment, myTip, totalTip, showTip, onReplyClick } = props
props const { text, content, userUsername, userAvatarUrl } = comment
const {
text,
content,
userUsername,
userName,
userAvatarUrl,
commenterPositionProb,
commenterPositionShares,
commenterPositionOutcome,
createdTime,
bountiesAwarded,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const totalAwarded = bountiesAwarded ?? 0
const { isReady, asPath } = useRouter() const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null) const commentRef = useRef<HTMLDivElement>(null)
@ -130,91 +225,33 @@ export const FeedComment = memo(function FeedComment(props: {
ref={commentRef} ref={commentRef}
id={comment.id} id={comment.id}
className={clsx( className={clsx(
'relative', 'hover:bg-greyscale-1 ml-10 gap-2 transition-colors',
indent ? 'ml-6' : '', highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
)} )}
> >
{/*draw a gray line from the comment to the left:*/} <Col className="-ml-3">
{indent ? ( <Avatar size="xs" username={userUsername} avatarUrl={userAvatarUrl} />
<span <span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="bg-greyscale-3 mx-auto h-full w-[1.5px]"
aria-hidden="true" aria-hidden="true"
/> />
) : null} </Col>
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} /> <Col className="w-full">
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3"> <FeedCommentHeader comment={comment} contract={contract} />
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
{comment.betId == null &&
commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
prob={commenterPositionProb}
outcome={commenterPositionOutcome}
contract={contract}
/>
</>
)}
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={createdTime}
elementId={comment.id}
/>
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div>
<Content <Content
className="mt-2 text-[15px] text-gray-700" className="text-greyscale-7 mt-2 grow text-[14px]"
content={content || text} content={content || text}
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <CommentActions
{onReplyClick && ( onReplyClick={onReplyClick}
<button comment={comment}
className="font-bold hover:underline" showTip={showTip}
onClick={() => onReplyClick(comment)} myTip={myTip}
> totalTip={totalTip}
Reply contract={contract}
</button> />
)} </Col>
{showTip && (
<Tipper
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</div>
</Row> </Row>
) )
}) })
@ -273,3 +310,74 @@ export function ContractCommentInput(props: {
/> />
) )
} }
export function FeedCommentHeader(props: {
comment: ContractComment
contract: Contract
}) {
const { comment, contract } = props
const {
userUsername,
userName,
commenterPositionProb,
commenterPositionShares,
commenterPositionOutcome,
createdTime,
bountiesAwarded,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const totalAwarded = bountiesAwarded ?? 0
return (
<Row>
<div className="text-greyscale-6 mt-0.5 text-xs">
<UserLink username={userUsername} name={userName} />{' '}
<span className="text-greyscale-4">
{comment.betId == null &&
commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
prob={commenterPositionProb}
outcome={commenterPositionOutcome}
contract={contract}
/>
</>
)}
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</span>
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={createdTime}
elementId={comment.id}
/>
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div>
</Row>
)
}

View File

@ -5,57 +5,46 @@ import { useFollows } from 'web/hooks/use-follows'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users' import { follow, unfollow } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { Button } from './button'
export function FollowButton(props: { export function FollowButton(props: {
isFollowing: boolean | undefined isFollowing: boolean | undefined
onFollow: () => void onFollow: () => void
onUnfollow: () => void onUnfollow: () => void
small?: boolean
className?: string
}) { }) {
const { isFollowing, onFollow, onUnfollow, small, className } = props const { isFollowing, onFollow, onUnfollow } = props
const user = useUser() const user = useUser()
const smallStyle = if (!user || isFollowing === undefined) return <></>
'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 || isFollowing === undefined)
return (
<button
className={clsx('btn btn-sm invisible', small && smallStyle, className)}
>
Follow
</button>
)
if (isFollowing) { if (isFollowing) {
return ( return (
<button <Button
className={clsx( size="sm"
'btn btn-outline btn-sm', color="gray-outline"
small && smallStyle, className="my-auto"
className
)}
onClick={withTracking(onUnfollow, 'unfollow')} onClick={withTracking(onUnfollow, 'unfollow')}
> >
Following Following
</button> </Button>
) )
} }
return ( return (
<button <Button
className={clsx('btn btn-sm', small && smallStyle, className)} size="sm"
color="indigo"
className="my-auto"
onClick={withTracking(onFollow, 'follow')} onClick={withTracking(onFollow, 'follow')}
> >
Follow Follow
</button> </Button>
) )
} }
export function UserFollowButton(props: { userId: string; small?: boolean }) { export function UserFollowButton(props: { userId: string }) {
const { userId, small } = props const { userId } = props
const user = useUser() const user = useUser()
const following = useFollows(user?.id) const following = useFollows(user?.id)
const isFollowing = following?.includes(userId) const isFollowing = following?.includes(userId)
@ -67,7 +56,6 @@ export function UserFollowButton(props: { userId: string; small?: boolean }) {
isFollowing={isFollowing} isFollowing={isFollowing}
onFollow={() => follow(user.id, userId)} onFollow={() => follow(user.id, userId)}
onUnfollow={() => unfollow(user.id, userId)} onUnfollow={() => unfollow(user.id, userId)}
small={small}
/> />
) )
} }

View File

@ -1,4 +1,4 @@
import { Button } from 'web/components/button' import { IconButton } from 'web/components/button'
import { import {
Contract, Contract,
followContract, followContract,
@ -33,9 +33,8 @@ export const FollowMarketButton = (props: {
noTap noTap
noFade noFade
> >
<Button <IconButton
size={'sm'} size="2xs"
color={'gray-white'}
onClick={async () => { onClick={async () => {
if (!user) return firebaseLogin() if (!user) return firebaseLogin()
if (followers?.includes(user.id)) { if (followers?.includes(user.id)) {
@ -65,18 +64,12 @@ export const FollowMarketButton = (props: {
> >
{watching ? ( {watching ? (
<Col className={'items-center gap-x-2 sm:flex-row'}> <Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon <EyeOffIcon className={clsx('h-5 w-5')} aria-hidden="true" />
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Unwatch */} {/* Unwatch */}
</Col> </Col>
) : ( ) : (
<Col className={'items-center gap-x-2 sm:flex-row'}> <Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeIcon <EyeIcon className={clsx('h-5 w-5')} aria-hidden="true" />
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */} {/* Watch */}
</Col> </Col>
)} )}
@ -87,7 +80,7 @@ export const FollowMarketButton = (props: {
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched' followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`} } a question!`}
/> />
</Button> </IconButton>
</Tooltip> </Tooltip>
) )
} }

View File

@ -1,5 +1,4 @@
import clsx from 'clsx' import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { User } from 'common/user' import { User } from 'common/user'
import { useState } from 'react' import { useState } from 'react'
@ -11,7 +10,6 @@ import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
import { useDiscoverUsers } from 'web/hooks/use-users' import { useDiscoverUsers } from 'web/hooks/use-users'
import { TextButton } from './text-button' import { TextButton } from './text-button'
import { track } from 'web/lib/service/analytics'
export function FollowingButton(props: { user: User; className?: string }) { export function FollowingButton(props: { user: User; className?: string }) {
const { user, className } = props const { user, className } = props
@ -40,37 +38,6 @@ export function FollowingButton(props: { user: User; className?: string }) {
) )
} }
export function EditFollowingButton(props: { user: User; className?: string }) {
const { user, className } = props
const [isOpen, setIsOpen] = useState(false)
const followingIds = useFollows(user.id)
const followerIds = useFollowers(user.id)
return (
<div
className={clsx(
className,
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
)}
onClick={() => {
setIsOpen(true)
track('edit following button')
}}
>
<PencilIcon className="inline h-4 w-4" />
Following
<FollowsDialog
user={user}
defaultTab="following"
followingIds={followingIds ?? []}
followerIds={followerIds ?? []}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</div>
)
}
export function FollowersButton(props: { user: User; className?: string }) { export function FollowersButton(props: { user: User; className?: string }) {
const { user, className } = props const { user, className } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)

View File

@ -103,7 +103,7 @@ export function CreateGroupButton(props: {
</Col> </Col>
{errorText && <div className={'text-error'}>{errorText}</div>} {errorText && <div className={'text-error'}>{errorText}</div>}
<div className="form-control w-full"> <div className="flex w-full flex-col">
<label className="mb-2 ml-1 mt-0">Group name</label> <label className="mb-2 ml-1 mt-0">Group name</label>
<Input <Input
placeholder={'Your group name'} placeholder={'Your group name'}

View File

@ -11,6 +11,7 @@ import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { useMemberIds } from 'web/hooks/use-group' import { useMemberIds } from 'web/hooks/use-group'
import { Input } from '../input' import { Input } from '../input'
import { Button } from '../button'
export function EditGroupButton(props: { group: Group; className?: string }) { export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props const { group, className } = props
@ -40,18 +41,18 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
return ( return (
<div className={clsx('flex p-1', className)}> <div className={clsx('flex p-1', className)}>
<div <Button
className={clsx( size="sm"
'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700' color="gray-white"
)} className="whitespace-nowrap"
onClick={() => updateOpen(!open)} onClick={() => updateOpen(!open)}
> >
<PencilIcon className="inline h-4 w-4" /> Edit <PencilIcon className="inline h-4 w-4" /> Edit
</div> </Button>
<Modal open={open} setOpen={updateOpen}> <Modal open={open} setOpen={updateOpen}>
<div className="h-full rounded-md bg-white p-8"> <div className="h-full rounded-md bg-white p-8">
<div className="form-control w-full"> <div className="flex w-full flex-col">
<label className="label"> <label className="px-1 py-2">
<span className="mb-1">Group name</span> <span className="mb-1">Group name</span>
</label> </label>
@ -65,8 +66,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<Spacer h={4} /> <Spacer h={4} />
<div className="form-control w-full"> <div className="flex w-full flex-col">
<label className="label"> <label className="px-1 py-2">
<span className="mb-0">Add members</span> <span className="mb-0">Add members</span>
</label> </label>
<FilterSelectUsers <FilterSelectUsers
@ -76,9 +77,10 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
/> />
</div> </div>
<div className="modal-action"> <div className="flex">
<label <Button
htmlFor="edit" color="red"
size="xs"
onClick={() => { onClick={() => {
if (confirm('Are you sure you want to delete this group?')) { if (confirm('Are you sure you want to delete this group?')) {
deleteGroup(group) deleteGroup(group)
@ -86,30 +88,24 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
router.replace('/groups') router.replace('/groups')
} }
}} }}
className={clsx(
'btn btn-sm btn-outline mr-auto self-center hover:border-red-500 hover:bg-red-500'
)}
> >
Delete Delete
</label> </Button>
<label <Button
htmlFor="edit" color="gray-white"
className={'btn'} size="xs"
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
Cancel Cancel
</label> </Button>
<label <Button
className={clsx( color="green"
'btn', disabled={saveDisabled}
saveDisabled ? 'btn-disabled' : 'btn-primary', loading={isSubmitting}
isSubmitting && 'loading'
)}
htmlFor="edit"
onClick={onSubmit} onClick={onSubmit}
> >
Save Save
</label> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -33,11 +33,9 @@ export function GroupOverviewPost(props: {
function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) { function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
const { group, post } = props const { group, post } = props
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
defaultValue: post?.content, defaultValue: post?.content,
disabled: isSubmitting,
}) })
async function savePost() { async function savePost() {
@ -76,10 +74,8 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
<Row className="gap-2"> <Row className="gap-2">
<Button <Button
onClick={async () => { onClick={async () => {
setIsSubmitting(true)
await savePost() await savePost()
setEditing(false) setEditing(false)
setIsSubmitting(false)
}} }}
> >
Save Save

View File

@ -116,10 +116,7 @@ export function GroupPosts(props: { posts: Post[]; group: Group }) {
</Col> </Col>
<Col> <Col>
{user && ( {user && (
<Button <Button onClick={() => setShowCreatePost(!showCreatePost)}>
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post Add a Post
</Button> </Button>
)} )}
@ -192,7 +189,9 @@ function GroupOverviewPinned(props: {
updateGroup(group, { pinnedItems: newPinned }) updateGroup(group, { pinnedItems: newPinned })
} }
return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? ( if (!group.pinnedItems || group.pinnedItems.length == 0) return <></>
return isEditable || (group.pinnedItems && group?.pinnedItems.length > 0) ? (
<PinnedItems <PinnedItems
posts={posts} posts={posts}
group={group} group={group}
@ -230,8 +229,8 @@ export function PinnedItems(props: {
return pinned.length > 0 || isEditable ? ( return pinned.length > 0 || isEditable ? (
<div> <div>
<Row className="mb-3 items-center justify-between"> <Row className=" items-center justify-between">
<SectionHeader label={'Pinned'} /> <SectionHeader label={'Featured'} href={`#`} />
{isEditable && ( {isEditable && (
<Button <Button
color="gray" color="gray"
@ -265,7 +264,7 @@ export function PinnedItems(props: {
</div> </div>
)} )}
{pinned.map((element, index) => ( {pinned.map((element, index) => (
<div className="relative my-2"> <div className="relative mb-4" key={element.key}>
{element} {element}
{editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />} {editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
@ -378,7 +377,7 @@ export function GroupAbout(props: {
<div className={'inline-flex items-center'}> <div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div> <div className="mr-1 text-gray-500">Created by</div>
<UserLink <UserLink
className="text-neutral" className="text-gray-700"
name={creator.name} name={creator.name}
username={creator.username} username={creator.username}
/> />
@ -430,7 +429,7 @@ export function GroupAbout(props: {
<CopyLinkButton <CopyLinkButton
url={shareUrl} url={shareUrl}
tracking="copy group share link" tracking="copy group share link"
buttonClassName="btn-md rounded-l-none" buttonClassName="rounded-l-none"
toastClassName={'-left-28 mt-1'} toastClassName={'-left-28 mt-1'}
/> />
</Col> </Col>

View File

@ -72,7 +72,7 @@ export function GroupSelector(props: {
) )
} }
return ( return (
<div className="form-control items-start"> <div className="flex flex-col items-start">
<Combobox <Combobox
as="div" as="div"
value={selectedGroup} value={selectedGroup}
@ -83,7 +83,7 @@ export function GroupSelector(props: {
{() => ( {() => (
<> <>
{showLabel && ( {showLabel && (
<Combobox.Label className="label justify-start gap-2 text-base"> <Combobox.Label className="justify-start gap-2 px-1 py-2 text-base">
Add to Group Add to Group
<InfoTooltip text="Question will be displayed alongside the other questions in the group." /> <InfoTooltip text="Question will be displayed alongside the other questions in the group." />
</Combobox.Label> </Combobox.Label>

View File

@ -125,9 +125,9 @@ export function JoinOrLeaveGroupButton(props: {
if (isMember) { if (isMember) {
return ( return (
<Button <Button
size="xs" size="sm"
color="gray-white" color="gray-outline"
className={`${className} border-greyscale-4 border !border-solid`} className={className}
onClick={withTracking(onLeaveGroup, 'leave group')} onClick={withTracking(onLeaveGroup, 'leave group')}
> >
Unfollow Unfollow
@ -139,8 +139,8 @@ export function JoinOrLeaveGroupButton(props: {
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" size="sm"
color="blue" color="indigo"
className={className} className={className}
onClick={withTracking(onJoinGroup, 'join group')} onClick={withTracking(onJoinGroup, 'join group')}
> >

View File

@ -2,21 +2,21 @@ import clsx from 'clsx'
import React from 'react' import React from 'react'
/** Text input. Wraps html `<input>` */ /** Text input. Wraps html `<input>` */
export const Input = (props: JSX.IntrinsicElements['input']) => { export const Input = (
const { className, ...rest } = props props: { error?: boolean } & JSX.IntrinsicElements['input']
) => {
const { error, className, ...rest } = props
return ( return (
<input <input
className={clsx('input input-bordered text-base md:text-sm', className)} className={clsx(
'h-12 rounded-md border bg-white px-4 shadow-sm transition-colors invalid:border-red-600 invalid:text-red-900 invalid:placeholder-red-300 focus:outline-none disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500 md:text-sm',
error
? 'border-red-300 text-red-900 placeholder-red-300 focus:border-red-600 focus:ring-red-500' // matches invalid: styles
: 'placeholder-greyscale-4 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500',
className
)}
{...rest} {...rest}
/> />
) )
} }
/*
TODO: replace daisyui style with our own. For reference:
james: text-lg placeholder:text-gray-400
inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md
austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm
*/

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Row } from './layout/row' import { Row } from './layout/row'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Table } from './table'
import { Title } from './title' import { Title } from './title'
interface LeaderboardEntry { interface LeaderboardEntry {
@ -31,9 +32,9 @@ export function Leaderboard<T extends LeaderboardEntry>(props: {
<div className="ml-2 text-gray-500">None yet</div> <div className="ml-2 text-gray-500">None yet</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table-zebra table-compact table w-full text-gray-500"> <Table>
<thead> <thead>
<tr className="p-2"> <tr>
<th>#</th> <th>#</th>
<th>Name</th> <th>Name</th>
{columns.map((column) => ( {columns.map((column) => (
@ -59,7 +60,7 @@ export function Leaderboard<T extends LeaderboardEntry>(props: {
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div> </div>
)} )}
</div> </div>

View File

@ -14,6 +14,7 @@ import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
import { Subtitle } from './subtitle' import { Subtitle } from './subtitle'
import { Table } from './table'
import { Title } from './title' import { Title } from './title'
export function LimitBets(props: { export function LimitBets(props: {
@ -74,7 +75,7 @@ export function LimitOrderTable(props: {
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
return ( return (
<table className="table-compact table w-full rounded text-gray-500"> <Table className="rounded">
<thead> <thead>
<tr> <tr>
{!isYou && <th></th>} {!isYou && <th></th>}
@ -89,7 +90,7 @@ export function LimitOrderTable(props: {
<LimitBet key={bet.id} bet={bet} contract={contract} isYou={isYou} /> <LimitBet key={bet.id} bet={bet} contract={contract} isYou={isYou} />
))} ))}
</tbody> </tbody>
</table> </Table>
) )
} }
@ -140,12 +141,9 @@ function LimitBet(props: {
{isCancelling ? ( {isCancelling ? (
<LoadingIndicator /> <LoadingIndicator />
) : ( ) : (
<button <Button size="2xs" color="gray-outline" onClick={onCancel}>
className="btn btn-xs btn-outline my-auto normal-case"
onClick={onCancel}
>
Cancel Cancel
</button> </Button>
)} )}
</td> </td>
)} )}

View File

@ -9,9 +9,9 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Claim, Manalink } from 'common/manalink' import { Claim, Manalink } from 'common/manalink'
import { ShareIconButton } from './share-icon-button' import { ShareIconButton } from './share-icon-button'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url' import getManalinkUrl from 'web/get-manalink-url'
import { IconButton } from './button'
export type ManalinkInfo = { export type ManalinkInfo = {
expiresTime: number | null expiresTime: number | null
@ -123,7 +123,7 @@ export function ManalinkCardFromView(props: {
src="/logo-white.svg" src="/logo-white.svg"
/> />
</Col> </Col>
<Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg"> <Row className="relative w-full rounded-b-lg bg-white px-4 py-2 align-middle text-lg">
<div <div
className={clsx( className={clsx(
'my-auto mb-1 w-full', 'my-auto mb-1 w-full',
@ -133,32 +133,23 @@ export function ManalinkCardFromView(props: {
{formatMoney(amount)} {formatMoney(amount)}
</div> </div>
<button <IconButton size="2xs" onClick={() => (window.location.href = qrUrl)}>
onClick={() => (window.location.href = qrUrl)}
className={clsx(contractDetailsButtonClassName)}
>
<QrcodeIcon className="h-6 w-6" /> <QrcodeIcon className="h-6 w-6" />
</button> </IconButton>
<ShareIconButton <ShareIconButton
toastClassName={'-left-48 min-w-[250%]'} toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'}
onCopyButtonClassName={
'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600'
}
copyPayload={getManalinkUrl(link.slug)} copyPayload={getManalinkUrl(link.slug)}
/> />
<button <IconButton
size="xs"
onClick={() => setShowDetails(!showDetails)} onClick={() => setShowDetails(!showDetails)}
className={clsx( className={clsx(
contractDetailsButtonClassName, showDetails ? ' text-indigo-600 hover:text-indigo-700' : ''
showDetails
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
: ''
)} )}
> >
<DotsHorizontalIcon className="h-[24px] w-5" /> <DotsHorizontalIcon className="h-5 w-5" />
</button> </IconButton>
</Row> </Row>
</Col> </Col>
<div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm"> <div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm">

View File

@ -14,6 +14,7 @@ import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { Input } from '../input' import { Input } from '../input'
import { ExpandingInput } from '../expanding-input' import { ExpandingInput } from '../expanding-input'
import { Select } from '../select'
export function CreateLinksButton(props: { export function CreateLinksButton(props: {
user: User user: User
@ -115,8 +116,8 @@ function CreateManalinkForm(props: {
> >
<Title className="!my-0" text="Create a Manalink" /> <Title className="!my-0" text="Create a Manalink" />
<div className="flex flex-col flex-wrap gap-x-5 gap-y-2"> <div className="flex flex-col flex-wrap gap-x-5 gap-y-2">
<div className="form-control flex-auto"> <div className="flex flex-auto flex-col">
<label className="label">Amount</label> <label className="px-1 py-2">Amount</label>
<div className="relative"> <div className="relative">
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> <span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$ M$
@ -135,8 +136,8 @@ function CreateManalinkForm(props: {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<div className="form-control w-full md:w-1/2"> <div className="flex w-full flex-col md:w-1/2">
<label className="label">Uses</label> <label className="px-1 py-2">Uses</label>
<Input <Input
type="number" type="number"
min="1" min="1"
@ -148,10 +149,9 @@ function CreateManalinkForm(props: {
} }
/> />
</div> </div>
<div className="form-control w-full md:w-1/2"> <div className="flex w-full flex-col md:w-1/2">
<label className="label">Expires in</label> <label className="px-1 py-2">Expires in</label>
<select <Select
className="!select !select-bordered"
value={expiresIn} value={expiresIn}
defaultValue={defaultExpire} defaultValue={defaultExpire}
onChange={(e) => { onChange={(e) => {
@ -160,11 +160,11 @@ function CreateManalinkForm(props: {
}} }}
> >
{expireOptions} {expireOptions}
</select> </Select>
</div> </div>
</div> </div>
<div className="form-control w-full"> <div className="flex w-full flex-col">
<label className="label">Message</label> <label className="px-1 py-2">Message</label>
<ExpandingInput <ExpandingInput
placeholder={defaultMessage} placeholder={defaultMessage}
maxLength={200} maxLength={200}

View File

@ -32,24 +32,19 @@ export function NumberInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <Input
<Input className={clsx('max-w-[200px] !text-lg', inputClassName)}
className={clsx( ref={inputRef}
'max-w-[200px] !text-lg', type="number"
error && 'input-error', pattern="[0-9]*"
inputClassName inputMode="numeric"
)} placeholder={placeholder ?? '0'}
ref={inputRef} maxLength={9}
type="number" value={numberString}
pattern="[0-9]*" error={!!error}
inputMode="numeric" disabled={disabled}
placeholder={placeholder ?? '0'} onChange={(e) => onChange(e.target.value.substring(0, 9))}
maxLength={9} />
value={numberString}
disabled={disabled}
onChange={(e) => onChange(e.target.value.substring(0, 9))}
/>
</label>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -20,6 +20,7 @@ import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Button } from './button'
export function NumericBetPanel(props: { export function NumericBetPanel(props: {
contract: NumericContract contract: NumericContract
@ -108,7 +109,7 @@ function NumericBuyPanel(props: {
}) })
} }
const betDisabled = isSubmitting || !betAmount || !bucketChoice || error const betDisabled = isSubmitting || !betAmount || !bucketChoice || !!error
const { newBet, newPool, newTotalShares, newTotalBets } = getNumericBetsInfo( const { newBet, newPool, newTotalShares, newTotalBets } = getNumericBetsInfo(
value ?? 0, value ?? 0,
@ -195,16 +196,14 @@ function NumericBuyPanel(props: {
<Spacer h={8} /> <Spacer h={8} />
{user && ( {user && (
<button <Button
className={clsx( disabled={betDisabled}
'btn flex-1', color="green"
betDisabled ? 'btn-disabled' : 'btn-primary', loading={isSubmitting}
isSubmitting ? 'loading' : '' onClick={submitBet}
)}
onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit'} Submit
</button> </Button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Bet submitted!</div>}

View File

@ -54,47 +54,48 @@ export default function Welcome() {
if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen)) if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen))
return <></> return <></>
return ( if (groupSelectorOpen)
<> return (
<GroupSelectorDialog <GroupSelectorDialog
open={groupSelectorOpen} open={groupSelectorOpen}
setOpen={() => setGroupSelectorOpen(false)} setOpen={() => setGroupSelectorOpen(false)}
/> />
)
<Modal open={open} setOpen={toggleOpen}> return (
<Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> <Modal open={open} setOpen={toggleOpen}>
{page === 0 && <Page0 />} <Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
{page === 1 && <Page1 />} {page === 0 && <Page0 />}
{page === 2 && <Page2 />} {page === 1 && <Page1 />}
{page === 3 && <Page3 />} {page === 2 && <Page2 />}
<Col> {page === 3 && <Page3 />}
<Row className="place-content-between"> <Col>
<ChevronLeftIcon <Row className="place-content-between">
className={clsx( <ChevronLeftIcon
'h-10 w-10 text-gray-400 hover:text-gray-500', className={clsx(
page === 0 ? 'disabled invisible' : '' 'h-10 w-10 text-gray-400 hover:text-gray-500',
)} page === 0 ? 'disabled invisible' : ''
onClick={decreasePage} )}
/> onClick={decreasePage}
<PageIndicator page={page} totalpages={TOTAL_PAGES} /> />
<ChevronRightIcon <PageIndicator page={page} totalpages={TOTAL_PAGES} />
className={clsx( <ChevronRightIcon
'h-10 w-10 text-indigo-500 hover:text-indigo-600', className={clsx(
page === TOTAL_PAGES - 1 ? 'disabled invisible' : '' 'h-10 w-10 text-indigo-500 hover:text-indigo-600',
)} page === TOTAL_PAGES - 1 ? 'disabled invisible' : ''
onClick={increasePage} )}
/> onClick={increasePage}
</Row> />
<u </Row>
className="self-center text-xs text-gray-500" <u
onClick={() => toggleOpen(false)} className="self-center text-xs text-gray-500"
> onClick={() => toggleOpen(false)}
I got the gist, exit welcome >
</u> I got the gist, exit welcome
</Col> </u>
</Col> </Col>
</Modal> </Col>
</> </Modal>
) )
} }
@ -117,6 +118,7 @@ function PageIndicator(props: { page: number; totalpages: number }) {
<Row> <Row>
{[...Array(totalpages)].map((e, i) => ( {[...Array(totalpages)].map((e, i) => (
<div <div
key={i}
className={clsx( className={clsx(
'mx-1.5 my-auto h-1.5 w-1.5 rounded-full', 'mx-1.5 my-auto h-1.5 w-1.5 rounded-full',
i === page ? 'bg-indigo-500' : 'bg-gray-300' i === page ? 'bg-indigo-500' : 'bg-gray-300'

View File

@ -69,9 +69,9 @@ export function PinnedSelectModal(props: {
} }
return ( return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> <Modal open={open} setOpen={setOpen} className={' sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> <Col className=" h-[85vh] w-full gap-4 overflow-scroll rounded-md bg-white">
<div className="p-8 pb-0"> <div className=" p-8 pb-0">
<Row> <Row>
<div className={'text-xl text-indigo-700'}>{title}</div> <div className={'text-xl text-indigo-700'}>{title}</div>

View File

@ -3,12 +3,13 @@ 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'
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 { Card } from './card'
import { CardHighlightOptions } from './contract/contracts-grid' import { CardHighlightOptions } from './contract/contracts-grid'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { UserLink } from './user-link' import { UserLink } from './user-link'
export function PostCard(props: { export function PostCard(props: {
@ -17,48 +18,45 @@ export function PostCard(props: {
highlightOptions?: CardHighlightOptions highlightOptions?: CardHighlightOptions
}) { }) {
const { post, onPostClick, highlightOptions } = props const { post, onPostClick, highlightOptions } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
const { itemIds: itemIds, highlightClassName } = highlightOptions || {} const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
if (!user) return <> </>
return ( return (
<div className="relative py-1"> <Card
<Card className={clsx(
className={clsx( 'group relative flex gap-3 py-4 px-6',
'relative flex gap-3 py-2 px-3', itemIds?.includes(post.id) && highlightClassName
itemIds?.includes(post.id) && highlightClassName )}
)} >
> <Row className="flex grow justify-between">
<div className="flex-shrink-0"> <Col className="gap-2">
<Avatar className="h-12 w-12" username={user?.username} /> <Row className="items-center justify-between">
</div> <Row className="items-center text-sm">
<div className=""> <Avatar
<div className="text-sm text-gray-500"> className="mx-1 h-7 w-7"
<UserLink username={post.creatorUsername}
className="text-neutral" avatarUrl={post.creatorAvatarUrl}
name={user?.name} />
username={user?.username} <UserLink
/> className="text-gray-400"
<span className="mx-1"></span> name={post.creatorName}
<span className="text-gray-500">{fromNow(post.createdTime)}</span> username={post.creatorUsername}
</div> />
<div className=" break-words text-lg font-medium text-gray-900"> <span className="mx-1 text-gray-400"></span>
<span className="text-gray-400">{fromNow(post.createdTime)}</span>
</Row>
<div 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
</div>
</Row>
<div className="break-words text-lg font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2">
{post.title} {post.title}
</div> </div>
<div className="font-small text-md break-words text-gray-500"> <div className="font-small text-md break-words text-gray-500">
{post.subtitle} {post.subtitle}
</div> </div>
</div> </Col>
<div> </Row>
<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"
@ -89,7 +87,7 @@ export function PostCard(props: {
/> />
</Link> </Link>
)} )}
</div> </Card>
) )
} }

View File

@ -4,18 +4,15 @@ import { getPseudoProbability } from 'common/pseudo-numeric'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { Input } from './input' import { Input } from './input'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
export function ProbabilityInput(props: { function ProbabilityInput(props: {
prob: number | undefined prob: number | undefined
onChange: (newProb: number | undefined) => void onChange: (newProb: number | undefined) => void
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
className?: string
inputClassName?: string inputClassName?: string
}) { }) {
const { prob, onChange, disabled, placeholder, className, inputClassName } = const { prob, onChange, disabled, placeholder, inputClassName } = props
props
const onProbChange = (str: string) => { const onProbChange = (str: string) => {
let prob = parseInt(str.replace(/\D/g, '')) let prob = parseInt(str.replace(/\D/g, ''))
@ -29,10 +26,10 @@ export function ProbabilityInput(props: {
} }
return ( return (
<Col className={className}> <Col>
<label className="input-group"> <label className="relative w-fit">
<Input <Input
className={clsx('max-w-[200px] !text-lg', inputClassName)} className={clsx('pr-2 !text-lg', inputClassName)}
type="number" type="number"
max={99} max={99}
min={1} min={1}
@ -44,9 +41,10 @@ export function ProbabilityInput(props: {
disabled={disabled} disabled={disabled}
onChange={(e) => onProbChange(e.target.value)} onChange={(e) => onProbChange(e.target.value)}
/> />
<span className="bg-gray-200 text-sm">%</span> <span className="text-greyscale-4 absolute top-1/2 right-10 my-auto -translate-y-1/2">
%
</span>
</label> </label>
<Spacer h={4} />
</Col> </Col>
) )
} }
@ -82,7 +80,7 @@ export function ProbabilityOrNumericInput(props: {
/> />
) : ( ) : (
<ProbabilityInput <ProbabilityInput
inputClassName="w-full max-w-none" inputClassName="w-24"
prob={prob} prob={prob}
onChange={setProb} onChange={setProb}
disabled={isSubmitting} disabled={isSubmitting}

View File

@ -1,5 +1,4 @@
import { Input } from './input' import { Input } from './input'
import { Row } from './layout/row'
export function ProbabilitySelector(props: { export function ProbabilitySelector(props: {
probabilityInt: number probabilityInt: number
@ -9,21 +8,19 @@ export function ProbabilitySelector(props: {
const { probabilityInt, setProbabilityInt, isSubmitting } = props const { probabilityInt, setProbabilityInt, isSubmitting } = props
return ( return (
<Row className="items-center gap-2"> <label className="flex items-center text-lg">
<label className="input-group input-group-lg text-lg"> <Input
<Input type="number"
type="number" value={probabilityInt}
value={probabilityInt} className="input-md w-28 !text-lg"
className="input-md w-28 !text-lg" disabled={isSubmitting}
disabled={isSubmitting} min={1}
min={1} max={99}
max={99} onChange={(e) =>
onChange={(e) => setProbabilityInt(parseInt(e.target.value.substring(0, 2)))
setProbabilityInt(parseInt(e.target.value.substring(0, 2))) }
} />
/> <span>%</span>
<span>%</span> </label>
</label>
</Row>
) )
} }

View File

@ -12,6 +12,7 @@ import { FilterSelectUsers } from 'web/components/filter-select-users'
import { getUser, updateUser } from 'web/lib/firebase/users' import { getUser, updateUser } from 'web/lib/firebase/users'
import { TextButton } from 'web/components/text-button' import { TextButton } from 'web/components/text-button'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { Button } from './button'
export function ReferralsButton(props: { export function ReferralsButton(props: {
user: User user: User
@ -89,11 +90,9 @@ function ReferralsDialog(props: {
maxUsers={1} maxUsers={1}
/> />
<Row className={'mt-0 justify-end'}> <Row className={'mt-0 justify-end'}>
<button <Button
className={ className={
referredBy.length === 0 referredBy.length === 0 ? 'hidden' : 'my-2 w-24'
? 'hidden'
: 'btn btn-primary btn-md my-2 w-24 normal-case'
} }
disabled={referredBy.length === 0 || isSubmitting} disabled={referredBy.length === 0 || isSubmitting}
onClick={() => { onClick={() => {
@ -114,7 +113,7 @@ function ReferralsDialog(props: {
}} }}
> >
Save Save
</button> </Button>
</Row> </Row>
<span className={'text-warning'}> <span className={'text-warning'}>
{referredBy.length > 0 && {referredBy.length > 0 &&

View File

@ -1,22 +1,16 @@
import { DateTimeTooltip } from './datetime-tooltip' import { DateTimeTooltip } from './datetime-tooltip'
import React, { useEffect, useState } from 'react'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { useIsClient } from 'web/hooks/use-is-client'
export function RelativeTimestamp(props: { time: number }) { export function RelativeTimestamp(props: { time: number }) {
const { time } = props const { time } = props
const [isClient, setIsClient] = useState(false) const isClient = useIsClient()
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}
> >
{isClient ? fromNow(time) : ''} {isClient && fromNow(time)}
</DateTimeTooltip> </DateTimeTooltip>
) )
} }

View File

@ -72,7 +72,6 @@ export function ResolutionPanel(props: {
className="mx-auto my-2" className="mx-auto my-2"
selected={outcome} selected={outcome}
onSelect={setOutcome} onSelect={setOutcome}
btnClassName={isSubmitting ? 'btn-disabled' : ''}
/> />
<Spacer h={4} /> <Spacer h={4} />
<div> <div>

17
web/components/select.tsx Normal file
View File

@ -0,0 +1,17 @@
import clsx from 'clsx'
export const Select = (props: JSX.IntrinsicElements['select']) => {
const { className, children, ...rest } = props
return (
<select
className={clsx(
'h-12 cursor-pointer self-start overflow-hidden rounded-md border border-gray-300 pl-4 pr-10 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500',
className
)}
{...rest}
>
ß{children}
</select>
)
}

View File

@ -1,59 +0,0 @@
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { User } from 'common/user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useState } from 'react'
import { Col } from './layout/col'
import clsx from 'clsx'
import { SellSharesModal } from './sell-modal'
export function SellButton(props: {
contract: BinaryContract | PseudoNumericContract
user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined
shares: number
panelClassName?: string
}) {
const { contract, user, sharesOutcome, shares, panelClassName } = props
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
if (sharesOutcome && user && mechanism === 'cpmm-1') {
return (
<Col className={'items-center'}>
<button
className={clsx(
'btn-sm w-24 gap-1',
// from the yes-no-selector:
'inline-flex items-center justify-center rounded-3xl border-2 p-2',
sharesOutcome === 'NO'
? 'hover:bg-primary-focus border-primary hover:border-primary-focus text-primary hover:text-white'
: 'border-red-400 text-red-500 hover:border-red-500 hover:bg-red-500 hover:text-white'
)}
onClick={() => setShowSellModal(true)}
>
Sell{' '}
{isPseudoNumeric
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
: sharesOutcome}
</button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + Math.floor(shares) + ' shares)'}
</div>
{showSellModal && (
<SellSharesModal
className={panelClassName}
contract={contract}
user={user}
userBets={userBets ?? []}
shares={shares}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</Col>
)
}
return <div />
}

View File

@ -8,6 +8,7 @@ import { OutcomeLabel } from './outcome-label'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
import { Button } from './button'
export function SellRow(props: { export function SellRow(props: {
contract: BinaryContract | PseudoNumericContract contract: BinaryContract | PseudoNumericContract
@ -37,17 +38,14 @@ export function SellRow(props: {
shares shares
</div> </div>
<button <Button
className="btn btn-sm" className="my-auto"
style={{ size="xs"
backgroundColor: 'white', color="gray-outline"
border: '2px solid',
color: '#3D4451',
}}
onClick={() => setShowSellModal(true)} onClick={() => setShowSellModal(true)}
> >
Sell Sell
</button> </Button>
</Row> </Row>
</Col> </Col>
{showSellModal && ( {showSellModal && (

View File

@ -5,34 +5,22 @@ import clsx from 'clsx'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' import { IconButton } from './button'
export function ShareIconButton(props: { export function ShareIconButton(props: {
buttonClassName?: string
onCopyButtonClassName?: string
toastClassName?: string toastClassName?: string
children?: React.ReactNode children?: React.ReactNode
iconClassName?: string iconClassName?: string
copyPayload: string copyPayload: string
}) { }) {
const { const { toastClassName, children, iconClassName, copyPayload } = props
buttonClassName,
onCopyButtonClassName,
toastClassName,
children,
iconClassName,
copyPayload,
} = props
const [showToast, setShowToast] = useState(false) const [showToast, setShowToast] = useState(false)
return ( return (
<div className="relative z-10 flex-shrink-0"> <div className="relative z-10 flex-shrink-0">
<button <IconButton
className={clsx( size="2xs"
contractDetailsButtonClassName, className={clsx('mt-1', showToast ? 'text-indigo-600' : '')}
buttonClassName,
showToast ? onCopyButtonClassName : ''
)}
onClick={() => { onClick={() => {
copyToClipboard(copyPayload) copyToClipboard(copyPayload)
track('copy share link') track('copy share link')
@ -41,11 +29,11 @@ export function ShareIconButton(props: {
}} }}
> >
<LinkIcon <LinkIcon
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} className={clsx(iconClassName ? iconClassName : 'h-5 w-5')}
aria-hidden="true" aria-hidden="true"
/> />
{children} {children}
</button> </IconButton>
{showToast && <ToastClipboard className={toastClassName} />} {showToast && <ToastClipboard className={toastClassName} />}
</div> </div>

View File

@ -10,7 +10,7 @@ export const SignInButton = (props: { className?: string }) => {
return ( return (
<Button <Button
size="lg" size="lg"
color="gray" color="gradient"
onClick={async () => { onClick={async () => {
// login, and then reload the page, to hit any SSR redirect (e.g. // login, and then reload the page, to hit any SSR redirect (e.g.
// redirecting from / to /home for logged in users) // redirecting from / to /home for logged in users)

21
web/components/table.tsx Normal file
View File

@ -0,0 +1,21 @@
import clsx from 'clsx'
/** `<table>` with styles. Expects table html (`<thead>`, `<td>` etc) */
export const Table = (props: {
zebra?: boolean
className?: string
children: React.ReactNode
}) => {
const { className, children } = props
return (
<table
className={clsx(
'w-full whitespace-nowrap text-left text-sm text-gray-500 [&_td]:p-2 [&_th]:p-2 [&>thead]:font-bold [&>tbody_tr:nth-child(odd)]:bg-white',
className
)}
>
{children}
</table>
)
}

View File

@ -8,7 +8,7 @@ export function ToastClipboard(props: { className?: string }) {
return ( return (
<Row <Row
className={clsx( className={clsx(
'border-base-300 absolute items-center' + 'border-greyscale-4 absolute items-center' +
'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' + 'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' +
'h-15 z-10 w-[15rem] p-2 pr-3 text-gray-500', 'h-15 z-10 w-[15rem] p-2 pr-3 text-gray-500',
className className

View File

@ -48,6 +48,7 @@ const BOT_USERNAMES = [
'MarketManagerBot', 'MarketManagerBot',
'Botlab', 'Botlab',
'JuniorBot', 'JuniorBot',
'ManifoldDream',
] ]
function BotBadge() { function BotBadge() {

View File

@ -95,7 +95,7 @@ export function UserPage(props: { user: User }) {
)} )}
<Col className="w-full gap-4 pl-5"> <Col className="w-full gap-4 pl-5">
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between"> <div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<Col> <Col>
<span className="break-anywhere text-lg font-bold sm:text-2xl"> <span className="break-anywhere text-lg font-bold sm:text-2xl">
{user.name} {user.name}
@ -291,7 +291,7 @@ export function ProfilePrivateStats(props: {
<Row className={'justify-between gap-4 sm:justify-end'}> <Row className={'justify-between gap-4 sm:justify-end'}>
<Col className={'text-greyscale-4 text-md sm:text-lg'}> <Col className={'text-greyscale-4 text-md sm:text-lg'}>
<span <span
className={clsx(profit >= 0 ? 'text-green-600' : 'text-red-400')} className={clsx(profit >= 0 ? 'text-teal-600' : 'text-red-400')}
> >
{formatMoney(profit)} {formatMoney(profit)}
</span> </span>
@ -303,7 +303,7 @@ export function ProfilePrivateStats(props: {
} }
onClick={() => setShowLoansModal(true)} onClick={() => setShowLoansModal(true)}
> >
<span className="text-green-600"> <span className="text-teal-600">
🏦 {formatMoney(user.nextLoanCached ?? 0)} 🏦 {formatMoney(user.nextLoanCached ?? 0)}
</span> </span>
<span className="mx-auto text-xs sm:text-sm">next loan</span> <span className="mx-auto text-xs sm:text-sm">next loan</span>

View File

@ -101,7 +101,7 @@ export function YesNoCancelSelector(props: {
<Button <Button
color={selected === 'MKT' ? 'blue' : 'gray'} color={selected === 'MKT' ? 'blue' : 'gray'}
onClick={() => onSelect('MKT')} onClick={() => onSelect('MKT')}
className={clsx(btnClassName, 'btn-sm')} className={btnClassName}
> >
PROB PROB
</Button> </Button>
@ -109,7 +109,7 @@ export function YesNoCancelSelector(props: {
<Button <Button
color={selected === 'CANCEL' ? 'yellow' : 'gray'} color={selected === 'CANCEL' ? 'yellow' : 'gray'}
onClick={() => onSelect('CANCEL')} onClick={() => onSelect('CANCEL')}
className={clsx(btnClassName, 'btn-sm')} className={btnClassName}
> >
N/A N/A
</Button> </Button>

View File

@ -3,10 +3,12 @@ import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { import {
Contract, Contract,
contracts, contracts,
getContractFromId,
listenForContract, listenForContract,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { useStateCheckEquality } from './use-state-check-equality' import { useStateCheckEquality } from './use-state-check-equality'
import { doc, DocumentData } from 'firebase/firestore' import { doc, DocumentData } from 'firebase/firestore'
import { useQuery } from 'react-query'
export const useContract = (contractId: string) => { export const useContract = (contractId: string) => {
const result = useFirestoreDocumentData<DocumentData, Contract>( const result = useFirestoreDocumentData<DocumentData, Contract>(
@ -18,6 +20,17 @@ export const useContract = (contractId: string) => {
return result.isLoading ? undefined : result.data return result.isLoading ? undefined : result.data
} }
export const useContractsFromIds = (contractIds: string[]) => {
const contractResult = useQuery(['contracts', contractIds], () =>
Promise.all(contractIds.map(getContractFromId))
)
const contracts = contractResult.data?.filter(
(contract): contract is Contract => !!contract
)
return contractResult.isLoading ? undefined : contracts
}
export const useContractWithPreload = ( export const useContractWithPreload = (
initial: Contract | null | undefined initial: Contract | null | undefined
) => { ) => {

View File

@ -0,0 +1,17 @@
import { GlobalConfig } from 'common/globalConfig'
import { useEffect } from 'react'
import { listenForGlobalConfig } from 'web/lib/firebase/globalConfig'
import { inMemoryStore, usePersistentState } from './use-persistent-state'
export const useGlobalConfig = () => {
const [globalConfig, setGlobalConfig] =
usePersistentState<GlobalConfig | null>(null, {
store: inMemoryStore(),
key: 'globalConfig',
})
useEffect(() => {
return listenForGlobalConfig(setGlobalConfig)
}, [setGlobalConfig])
return globalConfig
}

View File

@ -0,0 +1,7 @@
import { useEffect, useState } from 'react'
export const useIsClient = () => {
const [isClient, setIsClient] = useState(false)
useEffect(() => setIsClient(true), [])
return isClient
}

View File

@ -89,6 +89,17 @@ export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({
}, },
}) })
const store: Record<string, any> = {}
export const inMemoryStore = <T>(): PersistentStore<T> => ({
get: (k: string) => {
return store[k]
},
set: (k: string, v: T | undefined) => {
store[k] = v
},
})
export const usePersistentState = <T>( export const usePersistentState = <T>(
initial: T, initial: T,
persist?: PersistenceOptions<T> persist?: PersistenceOptions<T>

View File

@ -1,6 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { DateDoc, Post } from 'common/post' import { DateDoc, Post } from 'common/post'
import { listenForDateDocs, listenForPost } from 'web/lib/firebase/posts' import {
getAllPosts,
listenForDateDocs,
listenForPost,
} from 'web/lib/firebase/posts'
export const usePost = (postId: string | undefined) => { export const usePost = (postId: string | undefined) => {
const [post, setPost] = useState<Post | null | undefined>() const [post, setPost] = useState<Post | null | undefined>()
@ -38,6 +42,14 @@ export const usePosts = (postIds: string[]) => {
.sort((a, b) => b.createdTime - a.createdTime) .sort((a, b) => b.createdTime - a.createdTime)
} }
export const useAllPosts = () => {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
getAllPosts().then(setPosts)
}, [])
return posts
}
export const useDateDocs = () => { export const useDateDocs = () => {
const [dateDocs, setDateDocs] = useState<DateDoc[]>() const [dateDocs, setDateDocs] = useState<DateDoc[]>()

View File

@ -47,10 +47,7 @@ export const usePrefetchUsers = (userIds: string[]) => {
) )
} }
export const useUserContractMetricsByProfit = ( export const useUserContractMetricsByProfit = (userId: string, count = 50) => {
userId: string,
count: number
) => {
const positiveResult = useFirestoreQueryData<ContractMetrics>( const positiveResult = useFirestoreQueryData<ContractMetrics>(
['contract-metrics-descending', userId, count], ['contract-metrics-descending', userId, count],
getUserContractMetricsQuery(userId, count, 'desc') getUserContractMetricsQuery(userId, count, 'desc')
@ -71,10 +68,13 @@ export const useUserContractMetricsByProfit = (
if (!positiveResult.data || !negativeResult.data || !contracts) if (!positiveResult.data || !negativeResult.data || !contracts)
return undefined return undefined
const filteredContracts = filterDefined(contracts) as CPMMBinaryContract[] const filteredContracts = filterDefined(contracts).filter(
const filteredMetrics = metrics.filter( (c) => !c.isResolved
(m) => m.from && Math.abs(m.from.day.profit) >= 0.5 ) as CPMMBinaryContract[]
) const filteredMetrics = metrics
.filter((m) => m.from && Math.abs(m.from.day.profit) >= 0.5)
.filter((m) => filteredContracts.find((c) => c.id === m.contractId))
return { contracts: filteredContracts, metrics: filteredMetrics } return { contracts: filteredContracts, metrics: filteredMetrics }
} }

View File

@ -0,0 +1,33 @@
import {
CollectionReference,
doc,
collection,
getDoc,
updateDoc,
} from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import { GlobalConfig } from 'common/globalConfig'
import { listenForValue } from './utils'
const globalConfigCollection = collection(
db,
'globalConfig'
) as CollectionReference<GlobalConfig>
const globalConfigDoc = doc(globalConfigCollection, 'globalConfig')
export const getGlobalConfig = async () => {
return (await getDoc(globalConfigDoc)).data()
}
export function updateGlobalConfig(
globalConfig: GlobalConfig,
updates: Partial<GlobalConfig>
) {
return updateDoc(globalConfigDoc, updates)
}
export function listenForGlobalConfig(
setGlobalConfig: (globalConfig: GlobalConfig | null) => void
) {
return listenForValue(globalConfigDoc, setGlobalConfig)
}

Some files were not shown because too many files have changed in this diff Show More