Compare commits

...

56 Commits
alvea ... main

Author SHA1 Message Date
Phil
c762869b83
Migrate prod bot (#1052)
* Updated dev config to point to new Twitch dev bot hosting location.

* Updated prod config to point to new Twitch bot hosting location.
2022-10-14 16:41:06 +01:00
FRC
aaa09f49c0
New component to add arbitrary (but static) react embeds to posts programatically + midterms component (#1051) 2022-10-14 16:21:54 +01:00
FRC
4d214c01b4
Tipping in posts (#1045)
* Tipping in posts

* Rm itemType field
2022-10-14 13:05:07 +01:00
ingawei
0a70652667
fixed tip button bug (#1049)
* fixed tip button bug
2022-10-14 02:20:32 -05:00
mqp
b4162a0896 Auto-prettification 2022-10-14 06:49:23 +00:00
ingawei
2ecece02c3 Auto-remove unused imports 2022-10-14 06:48:40 +00:00
ingawei
4359ad0530
added cancelling tipping and lil coin (#1047)
* added cancelling tipping and lil coin
* forced timeout to fix weird toast bug
2022-10-14 01:47:51 -05:00
ingawei
3f8988bf27
Inga/colorful answer comments (#1040)
* changing answers in comments to match colors, no longer indenting those responses
2022-10-14 00:07:54 -05:00
Austin Chen
41a46aad9b Update stability-client to fix /dream 2022-10-13 21:44:22 -07:00
James Grugett
4097082c75
Dynamically choose outcome in Position tooltip (#1048) 2022-10-13 23:29:57 -05:00
mantikoros
58cd0e57bd track tournaments 2022-10-13 21:22:32 -05:00
Austin Chen
3a63503161 Fix @/% suggestion regression
See https://github.com/ueberdosis/tiptap/pull/3239
2022-10-13 19:06:03 -07:00
mantikoros
abd06f272b contract card: show traders instead of volume 2022-10-13 21:05:21 -05:00
Austin Chen
ab1c3020da Warn users about Dream breakage 2022-10-13 18:45:09 -07:00
Sinclair Chen
c044460a91
Don't break build on unused import (#1046)
* Fix lint warnings

* Let vercel build when unused import
2022-10-13 18:16:22 -07:00
mantikoros
7a6725ee77 contract card: less busy design 2022-10-13 19:56:16 -05:00
Austin Chen
823d1ddd4c Update stability-client 2022-10-13 17:01:05 -07:00
Austin Chen
7d490e0de1 Expand image on hover 2022-10-13 16:23:25 -07:00
Sinclair Chen
96d2255cb1 fix find and replace mistake 2022-10-13 15:15:45 -07:00
Sinclair Chen
4a139c5cc2 Fix limit prob styling 2022-10-13 14:55:48 -07:00
James Grugett
47eb8abed0 Don't include loans in email payout message 2022-10-13 16:05:40 -05:00
Sinclair Chen
903fcc83b3 Fix form-control styling 2022-10-13 13:20:04 -07:00
Austin Chen
0615bb2d4b
List ManifoldDream as a bot 2022-10-13 13:12:22 -07:00
mantikoros
3508c94634 tip button: fix tip color 2022-10-13 14:33:41 -05:00
mantikoros
29c0dfe3fe tip button: remove fill color 2022-10-13 14:27:47 -05:00
mantikoros
18a3b66164 dream label 2022-10-13 14:16:30 -05:00
James Grugett
9e4f41253f Filter out resolved markets from daily movers 2022-10-13 13:49:48 -05:00
Sinclair Chen
546b0231e7
Die Daisy (#1042)
* un-daisy labels

* un-daisy inputs

* un-daisy input groups

* fixup input

* un-daisy selects

* un-daisy slider

* Uninstall daisy. Migrate colors

* un-daisy tables

* fix input error styling

* lint
2022-10-13 11:23:42 -07:00
Phil
8bb44593f3
Updated dev config to point to new Twitch dev bot hosting location. (#1044) 2022-10-13 17:19:50 +01:00
Pico2x
2c2bc61788 Fix bug with parsing in abritrary react components 2022-10-13 16:56:35 +01:00
Pico2x
34c9dbb3e7 Revert "Revert "New implementation of market card embeddings (#1025)""
This reverts commit d6525bae9f.
2022-10-13 16:25:15 +01:00
Ian Philips
d6525bae9f Revert "New implementation of market card embeddings (#1025)"
This reverts commit 3fc53112b9.
2022-10-13 09:19:28 -06:00
Ian Philips
da32a756a8 Need not follow contract for tag notification 2022-10-13 08:41:33 -06:00
Ian Philips
fa476c78dd Handle numeric outcomes in movers 2022-10-13 08:18:16 -06:00
Ian Philips
e7ba7e715f default cursor on open answer 2022-10-13 08:04:01 -06:00
Ian Philips
9bf82c6082 Match colors on portfolio 2022-10-13 08:00:04 -06:00
Ian Philips
3e1876f0dc Add flaggedByUsernames to firestore rules 2022-10-13 07:58:14 -06:00
ingawei
5ba4a9dce7
Inga/tip button (#1043)
* added tip jar
* made market actions/comments and manalink buttons IconButtons
2022-10-13 01:53:26 -05:00
James Grugett
4e5b78f4ee Use in-memory store for home featured section data 2022-10-12 21:15:40 -05:00
James Grugett
bc6fab399e Make text grayer 2022-10-12 20:51:48 -05:00
James Grugett
c2d112e516 Position => shares 2022-10-12 20:51:20 -05:00
ingawei
3cbe8ad8bb
Inga/comment bounty fix (#1041)
* fixed bounty button in comments
2022-10-12 20:34:07 -05:00
ingawei
6226291e02 made comments smaller 2022-10-12 17:53:37 -07:00
Austin Chen
fa4dba4da3 Document the new /comment endpoint 2022-10-12 17:26:00 -07:00
Austin Chen
9eff69be75
Add /createcomment API endpoint (#946)
* /dream api: Upload StableDiffusion image to Firestore

* Minor tweaks

* Set content type on uploaded image

This makes it so the image doesn't auto-download when opened in a new tab

* Allow users to dream directly from within Manifold

* Remove unused import

* Implement a /comment endpoint which supports html and markdown

* Upgrade @tiptap/core to latest

* Update all tiptap deps to beta.199

* Add @tiptap/suggestion

* Import @tiptap/html in the right place

* ... add deps everywhere

So I have no idea how common deps work apparently

* Add tiptap/suggestion too

* Clean up dream

* More cleanups

* Rework /comment endpoint

* Move API to /comment

* Change imports in case that matters

* Add a couple todos

* Dynamically import micromark

* Parallellize gsutil with -m option

* Adding comments via api working, editor.tsx erroring out

* Unused import

* Remove disabled state from useTextEditor

Co-authored-by: Ian Philips <iansphilips@gmail.com>
2022-10-12 17:25:17 -07:00
James Grugett
789bec2a4f Expand replies by default (quick fix for comment links not working) 2022-10-12 17:49:04 -05:00
James Grugett
18042cd4d1 Return listener 2022-10-12 17:39:13 -05:00
mantikoros
04a126707b fix welcome dialog page freezing bug 2022-10-12 17:33:18 -05:00
mantikoros
7a412fdb0d add key props 2022-10-12 17:33:18 -05:00
James Grugett
e2dc4c6b8f Resolve markets again script 2022-10-12 17:30:21 -05:00
mantikoros
204d302d87 portfolio copy: "Open" => "Active" 2022-10-12 16:57:21 -05:00
James Grugett
ae39c1175b
Better resolve market payouts (#1038)
* Check payout preconditions first. Try to pay out market in 1 transaction.

* Format

* toBatch => lodash's chunk
2022-10-12 16:21:37 -05:00
Marshall Polaris
c44f223064
Fix some hydration issues (#1033)
* Extract `useIsClient` hook

* Fix a bunch of hydration bugs relevant to the contract page
2022-10-12 14:07:07 -07:00
mantikoros
aa717a767d backfill subsidyPool 2022-10-12 16:05:15 -05:00
Sinclair Chen
d9f57b7daa
Remove all daisy buttons (#1036)
* Tweak limit order UI and fix button

* Style all follow/unfollow buttons blue

also get rid of highlight-blue button

* remove all other uses of 'btn'

* Style group follow button like user follow

* lint and format
2022-10-12 12:27:17 -07:00
mantikoros
93ceaa52c4 payouts: fix subsidyPool undefined bug 2022-10-12 14:24:54 -05:00
127 changed files with 2316 additions and 1462 deletions

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
}, },
], ],

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

@ -10,6 +10,7 @@ export type AnyOutcomeType =
| PseudoNumeric | PseudoNumeric
| FreeResponse | FreeResponse
| Numeric | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)

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',
} }

View File

@ -70,7 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D', measurementId: 'G-SSFK1Q138D',
}, },
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app', twitchBotEndpoint: 'https://twitch-bot.manifold.markets',
cloudRunId: 'nggbo3neva', cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
adminEmails: [ adminEmails: [

View File

@ -1,8 +1,9 @@
export type Like = { export type Like = {
id: string // will be id of the object liked, i.e. contract.id id: string // will be id of the object liked, i.e. contract.id
userId: string userId: string
type: 'contract' type: 'contract' | 'post'
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 10 export const LIKE_TIP_AMOUNT = 10
export const TIP_UNDO_DURATION = 2000

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

@ -13,6 +13,9 @@ export type Post = {
creatorName: string creatorName: string
creatorUsername: string creatorUsername: string
creatorAvatarUrl?: string creatorAvatarUrl?: string
likedByUserIds?: string[]
likedByUserCount?: number
} }
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'
@ -51,6 +52,26 @@ export function parseMentions(data: JSONContent): string[] {
return uniq(mentions) return uniq(mentions)
} }
// 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 // StarterKit extensions
Blockquote, Blockquote,
@ -78,6 +99,8 @@ const stringParseExts = [
renderText: ({ node }) => renderText: ({ node }) =>
'[embed]' + node.attrs.src ? `(${node.attrs.src})` : '', '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
}), }),
skippableComponent('gridCardsComponent'),
skippableComponent('staticReactEmbedComponent'),
TiptapTweet.extend({ renderText: () => '[tweet]' }), TiptapTweet.extend({ renderText: () => '[tweet]' }),
TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }), TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }),
] ]
@ -86,3 +109,7 @@ export function richTextToString(text?: JSONContent) {
if (!text) return '' if (!text) return ''
return generateText(text, stringParseExts) return generateText(text, stringParseExts)
} }
export function htmlToRichText(html: string) {
return generateJSON(html, stringParseExts)
}

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

@ -110,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

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
}, },
], ],

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

@ -103,6 +103,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorName: creator.name, creatorName: creator.name,
creatorUsername: creator.username, creatorUsername: creator.username,
creatorAvatarUrl: creator.avatarUrl, creatorAvatarUrl: creator.avatarUrl,
itemType: 'post',
}) })
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

@ -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,8 +139,12 @@ 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,
creatorPayout,
liquidityPayouts,
collectedFees,
} = getPayouts(
outcome, outcome,
contract, contract,
bets, bets,
@ -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()
return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) 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) => {

View File

@ -22,8 +22,9 @@ module.exports = {
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
ignorePatterns: ['/public/mtg/*'],
env: { env: {
browser: true, browser: true,
node: true, node: true,

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

@ -100,8 +100,8 @@ export function AnswerItem(props: {
</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

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

@ -27,6 +27,13 @@ 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' import { ChatIcon } from '@heroicons/react/outline'
export function getAnswerColor(answer: Answer, answersArray: string[]) {
const colorIndex = answersArray.indexOf(answer.text)
return colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex]
: '#B1B1C7'
}
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void onAnswerCommentClick: (answer: Answer) => void
@ -107,8 +114,8 @@ export function AnswersPanel(props: {
? 'checkbox' ? 'checkbox'
: undefined : undefined
const colorSortedAnswer = useChartAnswers(contract).map( const answersArray = useChartAnswers(contract).map(
(value, _index) => value.text (answer, _index) => answer.text
) )
return ( return (
@ -139,8 +146,8 @@ export function AnswersPanel(props: {
key={item.id} key={item.id}
answer={item} answer={item}
contract={contract} contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
onAnswerCommentClick={onAnswerCommentClick} onAnswerCommentClick={onAnswerCommentClick}
color={getAnswerColor(item, answersArray)}
/> />
))} ))}
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
@ -185,18 +192,14 @@ export function AnswersPanel(props: {
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
colorIndex: number | undefined color: string
onAnswerCommentClick: (answer: Answer) => void onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const { answer, contract, colorIndex, onAnswerCommentClick } = props const { answer, contract, onAnswerCommentClick, color } = 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 =
colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex] + '55' // semi-transparent
: '#B1B1C755'
const colorWidth = 100 * Math.max(prob, 0.01) const colorWidth = 100 * Math.max(prob, 0.01)
return ( return (
@ -217,7 +220,7 @@ function OpenAnswer(props: {
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5' tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)} )}
style={{ style={{
background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`, background: `linear-gradient(to right, ${color}90 ${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">
@ -227,10 +230,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>

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

@ -271,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',
@ -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>
@ -785,11 +787,11 @@ function LimitOrderPanel(props: {
{user && ( {user && (
<Button <Button
size="xl" size="xl"
disabled={betDisabled ? true : false} disabled={betDisabled}
color={'indigo'} color={'indigo'}
loading={isSubmitting} loading={isSubmitting}
className="flex-1" className="flex-1"
onClick={betDisabled ? undefined : submitBet} onClick={submitBet}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
@ -980,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">
@ -1000,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

@ -25,10 +25,8 @@ export function BetsSummary(props: {
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const bets = props.userBets.filter((b) => !b.isAnte) const bets = props.userBets.filter((b) => !b.isAnte)
const { profitPercent, payout, profit, invested } = getContractBetMetrics( const { profitPercent, payout, profit, invested, hasShares } =
contract, getContractBetMetrics(contract, bets)
bets
)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale) const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) => const yesWinnings = sumBy(excludeSales, (bet) =>
@ -39,6 +37,7 @@ export function BetsSummary(props: {
) )
const position = yesWinnings - noWinnings const position = yesWinnings - noWinnings
const outcome = hasShares ? (position > 0 ? 'YES' : 'NO') : undefined
const prob = isBinary ? getProbability(contract) : 0 const prob = isBinary ? getProbability(contract) : 0
const expectation = prob * yesWinnings + (1 - prob) * noWinnings const expectation = prob * yesWinnings + (1 - prob) * noWinnings
@ -60,7 +59,9 @@ export function BetsSummary(props: {
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
Position{' '} Position{' '}
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." /> <InfoTooltip
text={`Number of shares you own on net. 1 ${outcome} share = M$1 if the market resolves ${outcome}.`}
/>
</div> </div>
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{position > 1e-7 ? ( {position > 1e-7 ? (

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-5 hover:text-greyscale-6',
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

@ -8,10 +8,12 @@ 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 Curve from 'web/public/custom-components/curve'
import { getAnswerColor } from './answers/answers-panel'
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 { CommentsAnswer } from './feed/feed-answer-comment-group'
import { ContractCommentInput } from './feed/feed-comments' import { ContractCommentInput } from './feed/feed-comments'
import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
@ -81,20 +83,30 @@ export function AnswerCommentInput(props: {
contract: Contract<AnyContractType> contract: Contract<AnyContractType>
answerResponse: Answer answerResponse: Answer
onCancelAnswerResponse?: () => void onCancelAnswerResponse?: () => void
answersArray: string[]
}) { }) {
const { contract, answerResponse, onCancelAnswerResponse } = props const { contract, answerResponse, onCancelAnswerResponse, answersArray } =
props
const replyTo = { const replyTo = {
id: answerResponse.id, id: answerResponse.id,
username: answerResponse.username, username: answerResponse.username,
} }
const color = getAnswerColor(answerResponse, answersArray)
return ( return (
<> <>
<CommentsAnswer answer={answerResponse} contract={contract} /> <Col>
<Row> <Row className="relative">
<div className="ml-1"> <div className="absolute -bottom-1 left-1.5">
<Curve size={28} strokeWidth={1} color="#D8D8EB" /> <Curve size={32} strokeWidth={1} color="#D8D8EB" />
</div> </div>
<div className="ml-[38px]">
<CommentsAnswer
answer={answerResponse}
contract={contract}
color={color}
/>
</div>
</Row>
<div className="relative w-full pt-1"> <div className="relative w-full pt-1">
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
@ -107,7 +119,7 @@ export function AnswerCommentInput(props: {
<XCircleIcon className="text-greyscale-5 hover:text-greyscale-6 absolute -top-1 -right-2 h-5 w-5" /> <XCircleIcon className="text-greyscale-5 hover:text-greyscale-6 absolute -top-1 -right-2 h-5 w-5" />
</button> </button>
</div> </div>
</Row> </Col>
</> </>
) )
} }

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

@ -1,7 +1,11 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatLargeNumber, formatPercent } from 'common/util/format' import {
formatLargeNumber,
formatPercent,
formatWithCommas,
} 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'
import { import {
@ -35,10 +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 { floatingEqual } from 'common/util/math'
import { ENV_CONFIG } from 'common/envs/constants'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -396,14 +400,25 @@ 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 && floatingEqual(metrics.totalShares.NO ?? 0, 0) ? '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')}>
<AvatarDetails <AvatarDetails
@ -418,7 +433,7 @@ 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 && metrics.hasShares && ( {showPosition && metrics && metrics.hasShares && (
<Row <Row
@ -426,19 +441,11 @@ export function ContractCardProbChange(props: {
'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])}{' '}
{Math.floor(metrics.totalShares[outcome])} {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

@ -3,6 +3,7 @@ import {
ExclamationIcon, ExclamationIcon,
PencilIcon, PencilIcon,
PlusCircleIcon, PlusCircleIcon,
UserGroupIcon,
} from '@heroicons/react/solid' } from '@heroicons/react/solid'
import clsx from 'clsx' import clsx from 'clsx'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
@ -34,6 +35,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,
@ -49,32 +51,41 @@ export function MiscDetails(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
}) { }) {
const { contract, showTime, hideGroupLink } = props const { contract, showTime, hideGroupLink } = props
const { volume, closeTime, isResolved, createdTime, resolutionTime } = const {
contract closeTime,
isResolved,
createdTime,
resolutionTime,
uniqueBettorCount,
} = 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 />
) : (contract.openCommentBounties ?? 0) > 0 ? ( ) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge /> <BountiedContractBadge />
) : volume > 0 || !isNew ? ( ) : !isNew || (uniqueBettorCount ?? 0) > 1 ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> <Row className={'shrink-0'}>
<UserGroupIcon className="mr-1 h-4 w-4" />
{uniqueBettorCount} trader{uniqueBettorCount !== 1 ? 's' : ''}
</Row>
) : ( ) : (
<NewContractBadge /> <NewContractBadge />
)} )}
@ -390,6 +401,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 +464,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 +491,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,23 +83,23 @@ 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> <tr>
<td>Type</td> <td>Type</td>
@ -186,7 +185,8 @@ export function ContractInfoDialog(props: {
<td> <td>
{mechanism === 'cpmm-1' && outcomeType === 'BINARY' {mechanism === 'cpmm-1' && outcomeType === 'BINARY'
? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO` ? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO`
: mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC' : mechanism === 'cpmm-1' &&
outcomeType === 'PSEUDO_NUMERIC'
? `${Math.round(pool.YES)} HIGHER, ${Math.round( ? `${Math.round(pool.YES)} HIGHER, ${Math.round(
pool.NO pool.NO
)} LOWER` )} LOWER`
@ -239,7 +239,7 @@ export function ContractInfoDialog(props: {
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </Table>
<Row className="flex-wrap"> <Row className="flex-wrap">
{mechanism === 'cpmm-1' && ( {mechanism === 'cpmm-1' && (
@ -249,6 +249,7 @@ export function ContractInfoDialog(props: {
</Row> </Row>
</Col> </Col>
</Modal> </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

@ -2,11 +2,11 @@ import { memo, useState } from 'react'
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 { CommentsAnswer } from '../feed/feed-answer-comment-group' import { FreeResponseComments } 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'
import { Contract } from 'common/contract' import { AnyContractType, Contract } from 'common/contract'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
import { ContractBetsTable } from '../bets-list' import { ContractBetsTable } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
@ -35,9 +35,7 @@ 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 TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import Curve from 'web/public/custom-components/curve'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { AnswerCommentInput } from '../comment-input'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -139,95 +137,27 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
) )
const topLevelComments = commentsByParent['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
const sortRow = comments.length > 0 && (
<Row className="mb-4 items-center justify-end gap-4">
<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>
)
if (contract.outcomeType === 'FREE_RESPONSE') {
return ( return (
<> <>
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortRow} <SortRow
{answerResponse && ( comments={comments}
<AnswerCommentInput contract={contract}
sort={sort}
onSortClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
/>
{contract.outcomeType === 'FREE_RESPONSE' && (
<FreeResponseComments
contract={contract} contract={contract}
answerResponse={answerResponse} answerResponse={answerResponse}
onCancelAnswerResponse={onCancelAnswerResponse} onCancelAnswerResponse={onCancelAnswerResponse}
/> topLevelComments={topLevelComments}
)} commentsByParent={commentsByParent}
{topLevelComments.map((parent) => {
if (parent.answerOutcome === undefined) {
return (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips} tips={tips}
/> />
)
}
const answer = contract.answers.find(
(answer) => answer.id === parent.answerOutcome
)
if (answer === undefined) {
console.error('Could not find answer that matches ID')
return <></>
}
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} {contract.outcomeType !== 'FREE_RESPONSE' &&
/> topLevelComments.map((parent) => (
</div>
</Row>
</>
)
})}
</>
)
} else {
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}
contract={contract} contract={contract}
@ -241,7 +171,6 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
))} ))}
</> </>
) )
}
}) })
const BetsTabContent = memo(function BetsTabContent(props: { const BetsTabContent = memo(function BetsTabContent(props: {
@ -310,3 +239,33 @@ const BetsTabContent = memo(function BetsTabContent(props: {
</> </>
) )
}) })
export function SortRow(props: {
comments: ContractComment[]
contract: Contract<AnyContractType>
sort: 'Best' | 'Newest'
onSortClick: () => void
}) {
const { comments, contract, sort, onSortClick } = props
if (comments.length <= 0) {
return <></>
}
return (
<Row className="mb-4 items-center justify-end gap-4">
<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={onSortClick}>
<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>
)
}

View File

@ -2,11 +2,11 @@ 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'
import { LikeMarketButton } from 'web/components/contract/like-market-button' import { LikeItemButton } from 'web/components/contract/like-item-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Tooltip } from '../tooltip' import { Tooltip } from '../tooltip'
@ -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} /> <LikeItemButton item={contract} user={user} itemType={'contract'} />
<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

@ -0,0 +1,71 @@
import React, { useMemo, useState } from 'react'
import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes'
import toast from 'react-hot-toast'
import { likeItem } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT, TIP_UNDO_DURATION } from 'common/like'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useItemTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
import { TipButton } from './tip-button'
import { Contract } from 'common/contract'
import { Post } from 'common/post'
import { TipToast } from '../tipper'
export function LikeItemButton(props: {
item: Contract | Post
user: User | null | undefined
itemType: string
}) {
const { item, user, itemType } = props
const tips = useItemTipTxns(item.id)
const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount))
}, [tips])
const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedItemIds = likes
?.filter((l) => l.type === 'contract' || l.type === 'post')
.map((l) => l.id)
const onLike = async () => {
if (!user) return firebaseLogin()
setIsLiking(true)
const timeoutId = setTimeout(() => {
likeItem(user, item, itemType).catch(() => setIsLiking(false))
}, 3000)
toast.custom(
() => (
<TipToast
userName={item.creatorUsername}
onUndoClick={() => {
clearTimeout(timeoutId)
}}
/>
),
{ duration: TIP_UNDO_DURATION }
)
}
return (
<TipButton
onClick={onLike}
tipAmount={LIKE_TIP_AMOUNT}
totalTipped={totalTipped}
userTipped={
!!user &&
(isLiking ||
userLikedItemIds?.includes(item.id) ||
(!likes && !!item.likedByUserIds?.includes(user.id)))
}
disabled={item.creatorId === user?.id}
/>
)
}

View File

@ -1,56 +0,0 @@
import React, { useMemo, useState } from 'react'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
import { TipButton } from './tip-button'
export function LikeMarketButton(props: {
contract: Contract
user: User | null | undefined
}) {
const { contract, user } = props
const tips = useMarketTipTxns(contract.id)
const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount))
}, [tips])
const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes
?.filter((l) => l.type === 'contract')
.map((l) => l.id)
const onLike = async () => {
if (!user) return firebaseLogin()
setIsLiking(true)
likeContract(user, contract).catch(() => setIsLiking(false))
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
}
return (
<TipButton
onClick={onLike}
tipAmount={LIKE_TIP_AMOUNT}
totalTipped={totalTipped}
userTipped={
!!user &&
(isLiking ||
userLikedContractIds?.includes(contract.id) ||
(!likes && !!contract.likedByUserIds?.includes(user.id)))
}
disabled={contract.creatorId === user?.id}
/>
)
}

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-teal-500' : 'text-red-400' 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

@ -1,10 +1,10 @@
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'
import Coin from 'web/public/custom-components/coin'
export function TipButton(props: { export function TipButton(props: {
tipAmount: number tipAmount: number
@ -14,55 +14,84 @@ 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={
disabled disabled
? `Tips (${formatMoney(totalTipped)})` ? `Total tips ${formatMoney(totalTipped)}`
: `Tip ${formatMoney(tipAmount)}` : `Tip ${formatMoney(tipAmount)}`
} }
placement="bottom" placement="bottom"
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}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx( className={clsx(
'h-5 w-5', 'px-2 py-1 text-xs', //2xs button
totalTipped > 0 ? 'mr-2' : '', 'text-greyscale-5 transition-transform disabled:cursor-not-allowed',
userTipped ? 'fill-teal-500 text-teal-500' : '' !disabled ? 'hover:text-greyscale-6' : ''
)} )}
/> onMouseOver={() => {
{totalTipped > 0 && ( if (!disabled) {
setHover(true)
}
}}
onMouseLeave={() => setHover(false)}
>
<Col className={clsx('relative')}>
<div <div
className={clsx( className={clsx(
'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 transition-all',
tipDisplay.length > 2 hover ? 'left-[6px] -top-[9px]' : 'left-[8px] -top-[10px]'
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)} )}
> >
{tipDisplay} <Coin
size={10}
color={
hover && !userTipped
? '#66667C'
: userTipped
? '#4f46e5'
: '#9191a7'
}
strokeWidth={2}
/>
</div> </div>
<TipJar
size={18}
color={
hover && !disabled && !userTipped
? '#66667C'
: userTipped
? '#4f46e5'
: '#9191a7'
}
/>
<div
className={clsx(
userTipped && 'text-indigo-600',
' absolute top-[2px] text-[0.5rem]',
tipDisplay.length === 1
? 'left-[7px]'
: tipDisplay.length === 2
? 'left-[4.5px]'
: tipDisplay.length > 2
? 'left-[4px] top-[2.5px] text-[0.35rem]'
: ''
)} )}
>
{totalTipped > 0 ? tipDisplay : ''}
</div>
</Col> </Col>
</Button> </button>
</Tooltip> </Tooltip>
) )
} }

View File

@ -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>
@ -93,9 +92,10 @@ export function CreatePost(props: { group?: Group }) {
<Button <Button
type="submit" type="submit"
loading={isSubmitting} color="green"
size="xl" size="xl"
disabled={isSubmitting || !isValid || upload.isLoading} loading={isSubmitting}
disabled={!isValid || upload.isLoading}
onClick={async () => { onClick={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await savePost(title) await savePost(title)

View File

@ -22,6 +22,7 @@ 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 GridComponent from './editor/tiptap-grid-cards'
import StaticReactEmbedComponent from './editor/tiptap-static-react-embed'
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'
@ -52,7 +53,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',
}, },
}) })
@ -81,6 +82,7 @@ export const editorExtensions = (simple = false): Extensions => [
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent, GridComponent,
StaticReactEmbedComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({
@ -90,18 +92,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,
@ -169,10 +170,6 @@ export function useTextEditor(props: {
}, },
}) })
useEffect(() => {
editor?.setEditable(!disabled)
}, [editor, disabled])
return { editor, upload } return { editor, upload }
} }
@ -263,7 +260,8 @@ 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 */}
@ -367,6 +365,7 @@ export function RichContent(props: {
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent, GridComponent,
StaticReactEmbedComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({

View File

@ -14,6 +14,7 @@ const beginsWith = (text: string, query: string) =>
export const contractMentionSuggestion: Suggestion = { export const contractMentionSuggestion: Suggestion = {
char: '%', char: '%',
allowSpaces: true, allowSpaces: true,
allowedPrefixes: [' '],
pluginKey: new PluginKey('contract-mention'), pluginKey: new PluginKey('contract-mention'),
items: async ({ query }) => items: async ({ query }) =>
orderBy( orderBy(

View File

@ -120,7 +120,10 @@ function DreamTab(props: {
<div className="text-sm">This may take ~10 seconds...</div> <div className="text-sm">This may take ~10 seconds...</div>
)} )}
{/* TODO: Allow the user to choose their own modifiers */} {/* TODO: Allow the user to choose their own modifiers */}
<div className="pt-2 text-sm text-gray-400">Modifiers: {MODIFIERS}</div> <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 */} {/* Show the current imageUrl */}
{/* TODO: Keep the other generated images, so the user can play with different attempts. */} {/* TODO: Keep the other generated images, so the user can play with different attempts. */}

View File

@ -14,6 +14,7 @@ const beginsWith = (text: string, query: string) =>
// copied from https://tiptap.dev/api/nodes/mention#usage // copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion: Suggestion = { export const mentionSuggestion: Suggestion = {
allowedPrefixes: [' '],
items: async ({ query }) => items: async ({ query }) =>
orderBy( orderBy(
(await getCachedUsers()).filter((u) => (await getCachedUsers()).filter((u) =>

View File

@ -0,0 +1,44 @@
import { mergeAttributes, Node } from '@tiptap/core'
import React from 'react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { StaticReactEmbed } from '../static-react-embed'
export default Node.create({
name: 'staticReactEmbedComponent',
group: 'block',
atom: true,
addAttributes() {
return {
embedName: '',
}
},
parseHTML() {
return [
{
tag: 'static-react-embed-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['static-react-embed-component', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(StaticReactEmbedComponent)
},
})
export function StaticReactEmbedComponent(props: any) {
const embedName = props.node.attrs.embedName
return (
<NodeViewWrapper className="static-react-embed-component">
<StaticReactEmbed embedName={embedName} />
</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(
@ -36,7 +38,7 @@ export function CopyLinkDateTimeComponent(props: {
'text-greyscale-4 hover:bg-greyscale-1.5 mx-1 whitespace-nowrap rounded-sm px-1 text-xs transition-colors' '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,32 +1,60 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Contract } from 'common/contract' import {
import React, { useEffect, useRef } from 'react' Contract,
FreeResponseContract,
MultipleChoiceContract,
} from 'common/contract'
import React, { useEffect, useRef, useState } from 'react'
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 { 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 { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { FeedCommentThread } from './feed-comments'
import { AnswerCommentInput } from '../comment-input'
import { ContractComment } from 'common/comment'
import { Dictionary, sortBy } from 'lodash'
import { getAnswerColor } from '../answers/answers-panel'
import Curve from 'web/public/custom-components/curve'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useChartAnswers } from '../charts/contract/choice'
export function CommentsAnswer(props: { answer: Answer; contract: Contract }) { export function CommentsAnswer(props: {
const { answer, contract } = props answer: Answer
const { username, avatarUrl, name, text } = answer contract: Contract
color: string
}) {
const { answer, contract, color } = props
const { username, name, text } = answer
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const router = useRouter()
const highlighted = router.asPath.endsWith(`#${answerElementId}`) const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false)
const answerRef = useRef<HTMLDivElement>(null) const answerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (highlighted && answerRef.current != null) { if (isReady && asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
}
}, [isReady, asPath, answerElementId])
useEffect(() => {
if (highlighted && answerRef.current) {
answerRef.current.scrollIntoView(true) answerRef.current.scrollIntoView(true)
} }
}, [highlighted]) }, [highlighted])
return ( return (
<Col className="bg-greyscale-2 w-fit gap-1 rounded-t-xl rounded-bl-xl py-2 px-4"> <Row>
<div
className="w-2"
style={{
background: color ? color : '#B1B1C7',
}}
/>
<Col className="w-fit bg-gray-100 py-1 pl-2 pr-2">
<Row className="gap-2"> <Row className="gap-2">
<Avatar username={username} avatarUrl={avatarUrl} size="xxs" /> <div className="text-greyscale-4 text-xs">
<div className="text-greyscale-6 text-xs">
<UserLink username={username} name={name} /> answered <UserLink username={username} name={name} /> answered
<CopyLinkDateTimeComponent <CopyLinkDateTimeComponent
prefix={contract.creatorUsername} prefix={contract.creatorUsername}
@ -38,5 +66,89 @@ export function CommentsAnswer(props: { answer: Answer; contract: Contract }) {
</Row> </Row>
<div className="text-sm">{text}</div> <div className="text-sm">{text}</div>
</Col> </Col>
</Row>
)
}
export function FreeResponseComments(props: {
contract: FreeResponseContract | MultipleChoiceContract
answerResponse: Answer | undefined
onCancelAnswerResponse?: () => void
topLevelComments: ContractComment[]
commentsByParent: Dictionary<[ContractComment, ...ContractComment[]]>
tips: CommentTipMap
}) {
const {
contract,
answerResponse,
onCancelAnswerResponse,
topLevelComments,
commentsByParent,
tips,
} = props
const answersArray = useChartAnswers(contract).map((answer) => answer.text)
return (
<>
{answerResponse && (
<AnswerCommentInput
contract={contract}
answerResponse={answerResponse}
onCancelAnswerResponse={onCancelAnswerResponse}
answersArray={answersArray}
/>
)}
{topLevelComments.map((parent) => {
if (parent.answerOutcome === undefined) {
return (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
)
}
const answer = contract.answers.find(
(answer) => answer.id === parent.answerOutcome
)
if (answer === undefined) {
console.error('Could not find answer that matches ID')
return <></>
}
const color = getAnswerColor(answer, answersArray)
return (
<>
<Row className="relative">
<div className="absolute -bottom-1 left-1.5">
<Curve size={32} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="ml-[38px]">
<CommentsAnswer
answer={answer}
contract={contract}
color={color}
/>
</div>
</Row>
<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>
</>
)
})}
</>
) )
} }

View File

@ -24,7 +24,7 @@ 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 { ReplyIcon } from '@heroicons/react/solid'
import { Button } from '../button' import { IconButton } from '../button'
import { ReplyToggle } from '../comments/reply-toggle' import { ReplyToggle } from '../comments/reply-toggle'
export type ReplyTo = { id: string; username: string } export type ReplyTo = { id: string; username: string }
@ -37,7 +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(false) const [seeReplies, setSeeReplies] = useState(true)
const user = useUser() const user = useUser()
const onSubmitComment = useEvent(() => setReplyTo(undefined)) const onSubmitComment = useEvent(() => setReplyTo(undefined))
@ -154,33 +154,43 @@ export function ParentFeedComment(props: {
numComments={numComments} numComments={numComments}
onClick={onSeeReplyClick} onClick={onSeeReplyClick}
/> />
<Row className="grow justify-end gap-2"> <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 && ( {onReplyClick && (
<Button <IconButton size={'xs'} onClick={() => onReplyClick(comment)}>
size={'sm'}
className={clsx(
'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0'
)}
color={'gray-white'}
onClick={() => onReplyClick(comment)}
>
<ReplyIcon className="h-5 w-5" /> <ReplyIcon className="h-5 w-5" />
</Button> </IconButton>
)} )}
{showTip && ( {showTip && (
<Tipper <Tipper comment={comment} myTip={myTip ?? 0} totalTip={totalTip ?? 0} />
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)} )}
{(contract.openCommentBounties ?? 0) > 0 && ( {(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} /> <AwardBountyButton comment={comment} contract={contract} />
)} )}
</Row> </Row>
</Row>
</Col>
</Row>
) )
} }
@ -233,30 +243,14 @@ export const FeedComment = memo(function FeedComment(props: {
content={content || text} content={content || text}
smallImage smallImage
/> />
<Row className="grow justify-end gap-2"> <CommentActions
{onReplyClick && ( onReplyClick={onReplyClick}
<Button
size={'sm'}
className={clsx(
'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0'
)}
color={'gray-white'}
onClick={() => onReplyClick(comment)}
>
<ReplyIcon className="h-5 w-5" />
</Button>
)}
{showTip && (
<Tipper
comment={comment} comment={comment}
myTip={myTip ?? 0} showTip={showTip}
totalTip={totalTip ?? 0} myTip={myTip}
totalTip={totalTip}
contract={contract}
/> />
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</Col> </Col>
</Row> </Row>
) )

View File

@ -11,25 +11,19 @@ export function FollowButton(props: {
isFollowing: boolean | undefined isFollowing: boolean | undefined
onFollow: () => void onFollow: () => void
onUnfollow: () => void onUnfollow: () => void
className?: string
}) { }) {
const { isFollowing, onFollow, onUnfollow, className } = props const { isFollowing, onFollow, onUnfollow } = props
const user = useUser() const user = useUser()
if (!user || isFollowing === undefined) if (!user || isFollowing === undefined) return <></>
return (
<Button size="sm" color="gray" className={clsx(className, 'invisible')}>
Follow
</Button>
)
if (isFollowing) { if (isFollowing) {
return ( return (
<Button <Button
size="sm" size="sm"
color="gray-outline" color="gray-outline"
className={clsx('my-auto', className)} className="my-auto"
onClick={withTracking(onUnfollow, 'unfollow')} onClick={withTracking(onUnfollow, 'unfollow')}
> >
Following Following
@ -41,7 +35,7 @@ export function FollowButton(props: {
<Button <Button
size="sm" size="sm"
color="indigo" color="indigo"
className={clsx(className, 'my-auto')} className="my-auto"
onClick={withTracking(onFollow, 'follow')} onClick={withTracking(onFollow, 'follow')}
> >
Follow Follow

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>
)} )}
@ -267,7 +264,7 @@ export function PinnedItems(props: {
</div> </div>
)} )}
{pinned.map((element, index) => ( {pinned.map((element, index) => (
<div className="relative mb-4"> <div className="relative mb-4" key={element.key}>
{element} {element}
{editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />} {editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
@ -380,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}
/> />
@ -432,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>
) )
} }

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

@ -0,0 +1,212 @@
import { useEffect } from 'react'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
import {
StateElectionMap,
StateElectionMarket,
} from './usa-map/state-election-map'
import { useState } from 'react'
import { LoadingIndicator } from './loading-indicator'
import { CPMMBinaryContract } from 'common/contract'
export function MidtermsMaps(props: { mapType: string }) {
const { mapType } = props
const [contracts, setContracts] = useState<CPMMBinaryContract[] | null>(null)
useEffect(() => {
const getContracts = async () => {
if (props.mapType === 'senate') {
const senateContracts = await Promise.all(
senateMidterms.map((m) =>
getContractFromSlug(m.slug).then((c) => c ?? null)
)
)
setContracts(senateContracts as CPMMBinaryContract[])
} else if (props.mapType === 'governor') {
const governorContracts = await Promise.all(
governorMidterms.map((m) =>
getContractFromSlug(m.slug).then((c) => c ?? null)
)
)
setContracts(governorContracts as CPMMBinaryContract[])
}
}
getContracts()
}, [props.mapType, setContracts])
return contracts ? (
<StateElectionMap
markets={mapType == 'senate' ? senateMidterms : governorMidterms}
contracts={contracts}
/>
) : (
<LoadingIndicator />
)
}
const senateMidterms: StateElectionMarket[] = [
{
state: 'AZ',
creatorUsername: 'BTE',
slug: 'will-blake-masters-win-the-arizona',
isWinRepublican: true,
},
{
state: 'OH',
creatorUsername: 'BTE',
slug: 'will-jd-vance-win-the-ohio-senate-s',
isWinRepublican: true,
},
{
state: 'WI',
creatorUsername: 'BTE',
slug: 'will-ron-johnson-be-reelected-in-th',
isWinRepublican: true,
},
{
state: 'FL',
creatorUsername: 'BTE',
slug: 'will-marco-rubio-be-reelected-to-th',
isWinRepublican: true,
},
{
state: 'PA',
creatorUsername: 'MattP',
slug: 'will-dr-oz-be-elected-to-the-us-sen',
isWinRepublican: true,
},
{
state: 'GA',
creatorUsername: 'NcyRocks',
slug: 'will-a-democrat-win-the-2022-us-sen-3d2432ba6d79',
isWinRepublican: false,
},
{
state: 'NV',
creatorUsername: 'NcyRocks',
slug: 'will-a-democrat-win-the-2022-us-sen',
isWinRepublican: false,
},
{
state: 'NC',
creatorUsername: 'NcyRocks',
slug: 'will-a-democrat-win-the-2022-us-sen-6f1a901e1fcf',
isWinRepublican: false,
},
{
state: 'NH',
creatorUsername: 'NcyRocks',
slug: 'will-a-democrat-win-the-2022-us-sen-23194a72f1b7',
isWinRepublican: false,
},
{
state: 'UT',
creatorUsername: 'SG',
slug: 'will-mike-lee-win-the-2022-utah-sen',
isWinRepublican: true,
},
{
state: 'CO',
creatorUsername: 'SG',
slug: 'will-michael-bennet-win-the-2022-co',
isWinRepublican: false,
},
]
const governorMidterms: StateElectionMarket[] = [
{
state: 'TX',
creatorUsername: 'LarsDoucet',
slug: 'republicans-will-win-the-2022-texas',
isWinRepublican: true,
},
{
state: 'GA',
creatorUsername: 'MattP',
slug: 'will-stacey-abrams-win-the-2022-geo',
isWinRepublican: false,
},
{
state: 'FL',
creatorUsername: 'Tetraspace',
slug: 'if-charlie-crist-is-the-democratic',
isWinRepublican: false,
},
{
state: 'PA',
creatorUsername: 'JonathanMast',
slug: 'will-josh-shapiro-win-the-2022-penn',
isWinRepublican: false,
},
{
state: 'PA',
creatorUsername: 'JonathanMast',
slug: 'will-josh-shapiro-win-the-2022-penn',
isWinRepublican: false,
},
{
state: 'CO',
creatorUsername: 'ScottLawrence',
slug: 'will-jared-polis-be-reelected-as-co',
isWinRepublican: false,
},
{
state: 'OR',
creatorUsername: 'Tetraspace',
slug: 'if-tina-kotek-is-the-2022-democrati',
isWinRepublican: false,
},
{
state: 'MD',
creatorUsername: 'Tetraspace',
slug: 'if-wes-moore-is-the-2022-democratic',
isWinRepublican: false,
},
{
state: 'AK',
creatorUsername: 'SG',
slug: 'will-a-republican-win-the-2022-alas',
isWinRepublican: true,
},
{
state: 'AZ',
creatorUsername: 'SG',
slug: 'will-a-republican-win-the-2022-ariz',
isWinRepublican: true,
},
{
state: 'AZ',
creatorUsername: 'SG',
slug: 'will-a-republican-win-the-2022-ariz',
isWinRepublican: true,
},
{
state: 'WI',
creatorUsername: 'SG',
slug: 'will-a-democrat-win-the-2022-wiscon',
isWinRepublican: false,
},
{
state: 'NV',
creatorUsername: 'SG',
slug: 'will-a-democrat-win-the-2022-nevada',
isWinRepublican: false,
},
{
state: 'KS',
creatorUsername: 'SG',
slug: 'will-a-democrat-win-the-2022-kansas',
isWinRepublican: false,
},
{
state: 'NV',
creatorUsername: 'SG',
slug: 'will-a-democrat-win-the-2022-new-me',
isWinRepublican: false,
},
{
state: 'ME',
creatorUsername: 'SG',
slug: 'will-a-democrat-win-the-2022-maine',
isWinRepublican: false,
},
]

View File

@ -32,13 +32,8 @@ export function NumberInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group">
<Input <Input
className={clsx( className={clsx('max-w-[200px] !text-lg', inputClassName)}
'max-w-[200px] !text-lg',
error && 'input-error',
inputClassName
)}
ref={inputRef} ref={inputRef}
type="number" type="number"
pattern="[0-9]*" pattern="[0-9]*"
@ -46,10 +41,10 @@ export function NumberInput(props: {
placeholder={placeholder ?? '0'} placeholder={placeholder ?? '0'}
maxLength={9} maxLength={9}
value={numberString} value={numberString}
error={!!error}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange(e.target.value.substring(0, 9))} 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,13 +54,15 @@ export default function Welcome() {
if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen)) if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen))
return <></> return <></>
if (groupSelectorOpen)
return ( return (
<>
<GroupSelectorDialog <GroupSelectorDialog
open={groupSelectorOpen} open={groupSelectorOpen}
setOpen={() => setGroupSelectorOpen(false)} setOpen={() => setGroupSelectorOpen(false)}
/> />
)
return (
<Modal open={open} setOpen={toggleOpen}> <Modal open={open} setOpen={toggleOpen}>
<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"> <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 === 0 && <Page0 />} {page === 0 && <Page0 />}
@ -94,7 +96,6 @@ export default function Welcome() {
</Col> </Col>
</Col> </Col>
</Modal> </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

@ -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,8 +8,7 @@ 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}
@ -24,6 +22,5 @@ export function ProbabilitySelector(props: {
/> />
<span>%</span> <span>%</span>
</label> </label>
</Row>
) )
} }

View File

@ -7,7 +7,7 @@ import { useUserLikedContracts } from 'web/hooks/use-likes'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { XIcon } from '@heroicons/react/outline' import { XIcon } from '@heroicons/react/outline'
import { unLikeContract } from 'web/lib/firebase/likes' import { unLikeItem } from 'web/lib/firebase/likes'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
export function UserLikesButton(props: { user: User; className?: string }) { export function UserLikesButton(props: { user: User; className?: string }) {
@ -36,7 +36,7 @@ export function UserLikesButton(props: { user: User; className?: string }) {
</SiteLink> </SiteLink>
<XIcon <XIcon
className="ml-2 h-5 w-5 shrink-0 cursor-pointer" className="ml-2 h-5 w-5 shrink-0 cursor-pointer"
onClick={() => unLikeContract(user.id, likedContract.id)} onClick={() => unLikeItem(user.id, likedContract.id)}
/> />
</Row> </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

@ -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

@ -0,0 +1,20 @@
import { useState, useEffect } from 'react'
import { MidtermsMaps } from './midterms-maps'
export function StaticReactEmbed(props: { embedName: string }) {
const { embedName } = props
const [embed, setEmbed] = useState<JSX.Element | null>(null)
useEffect(() => {
const governorMidtermsMap = <MidtermsMaps mapType="governor" />
const senateMidtermsMap = <MidtermsMaps mapType="senate" />
if (embedName === 'governor-midterms-map') {
setEmbed(governorMidtermsMap)
} else if (embedName === 'senate-midterms-map') {
setEmbed(senateMidtermsMap)
}
}, [embedName, setEmbed])
return <div>{embed}</div>
}

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

@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { debounce } from 'lodash'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
@ -9,8 +8,10 @@ import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { TipButton } from './contract/tip-button' import { TipButton } from './contract/tip-button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { LIKE_TIP_AMOUNT } from 'common/like' import { LIKE_TIP_AMOUNT, TIP_UNDO_DURATION } from 'common/like'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Button } from './button'
import clsx from 'clsx'
export function Tipper(prop: { export function Tipper(prop: {
comment: Comment comment: Comment
@ -19,24 +20,13 @@ export function Tipper(prop: {
}) { }) {
const { comment, myTip, totalTip } = prop const { comment, myTip, totalTip } = prop
// This is a temporary tipping amount before it actually gets confirmed. This is so tha we dont accidentally tip more than you have
const [tempTip, setTempTip] = useState(0)
const me = useUser() const me = useUser()
const [localTip, setLocalTip] = useState(myTip) const [saveTip] = useState(
() => async (user: User, comment: Comment, change: number) => {
// listen for user being set
const initialized = useRef(false)
useEffect(() => {
if (myTip && !initialized.current) {
setLocalTip(myTip)
initialized.current = true
}
}, [myTip])
const total = totalTip - myTip + localTip
// declare debounced function only on first render
const [saveTip] = useState(() =>
debounce(async (user: User, comment: Comment, change: number) => {
if (change === 0) { if (change === 0) {
return return
} }
@ -67,30 +57,88 @@ export function Tipper(prop: {
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,
}) })
}, 1500) }
) )
// instant save on unrender
useEffect(() => () => void saveTip.flush(), [saveTip])
const addTip = (delta: number) => { const addTip = (delta: number) => {
setLocalTip(localTip + delta) setTempTip((tempTip) => tempTip + delta)
me && saveTip(me, comment, localTip - myTip + delta) const timeoutId = setTimeout(() => {
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) me &&
saveTip(me, comment, delta)
.then(() => setTempTip((tempTip) => tempTip - delta))
.catch((e) => console.error(e))
}, TIP_UNDO_DURATION + 1000)
toast.custom(
() => (
<TipToast
userName={comment.userName}
onUndoClick={() => {
clearTimeout(timeoutId)
setTempTip((tempTip) => tempTip - delta)
}}
/>
),
{ duration: TIP_UNDO_DURATION }
)
} }
const canUp = const canUp =
me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT me && comment.userId !== me.id && me.balance - tempTip >= LIKE_TIP_AMOUNT
return ( return (
<Row className="items-center gap-0.5"> <Row className="items-center gap-0.5">
<TipButton <TipButton
tipAmount={LIKE_TIP_AMOUNT} tipAmount={LIKE_TIP_AMOUNT}
totalTipped={total} totalTipped={totalTip}
onClick={() => addTip(+LIKE_TIP_AMOUNT)} onClick={() => addTip(+LIKE_TIP_AMOUNT)}
userTipped={localTip > 0} userTipped={tempTip > 0 || myTip > 0}
disabled={!canUp} disabled={!canUp}
isCompact isCompact
/> />
</Row> </Row>
) )
} }
export function TipToast(props: { userName: string; onUndoClick: () => void }) {
const { userName, onUndoClick } = props
const [cancelled, setCancelled] = useState(false)
// There is a strange bug with toast where sometimes if you interact with one popup, the others will not dissappear at the right time, overriding it for now with this
const [timedOut, setTimedOut] = useState(false)
setTimeout(() => {
setTimedOut(true)
}, TIP_UNDO_DURATION)
if (timedOut) {
return <></>
}
return (
<div className="relative overflow-hidden rounded-lg bg-white drop-shadow-md">
<div
className={clsx(
'animate-progress-loading absolute bottom-0 z-10 h-1 w-full bg-indigo-600',
cancelled ? 'hidden' : ''
)}
/>
<Row className="text-greyscale-6 items-center gap-4 px-4 py-2 text-sm">
<div className={clsx(cancelled ? 'hidden' : 'inline')}>
Tipping {userName} {formatMoney(LIKE_TIP_AMOUNT)}...
</div>
<div className={clsx('py-1', cancelled ? 'inline' : 'hidden')}>
Cancelled tipping
</div>
<Button
className={clsx(cancelled ? 'hidden' : 'inline')}
size="xs"
color="gray-outline"
onClick={() => {
onUndoClick()
setCancelled(true)
}}
disabled={cancelled}
>
Cancel
</Button>
</Row>
</div>
)
}

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>

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

@ -1,12 +1,17 @@
import { GlobalConfig } from 'common/globalConfig' import { GlobalConfig } from 'common/globalConfig'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { listenForGlobalConfig } from 'web/lib/firebase/globalConfig' import { listenForGlobalConfig } from 'web/lib/firebase/globalConfig'
import { inMemoryStore, usePersistentState } from './use-persistent-state'
export const useGlobalConfig = () => { export const useGlobalConfig = () => {
const [globalConfig, setGlobalConfig] = useState<GlobalConfig | null>(null) const [globalConfig, setGlobalConfig] =
usePersistentState<GlobalConfig | null>(null, {
store: inMemoryStore(),
key: 'globalConfig',
})
useEffect(() => { useEffect(() => {
listenForGlobalConfig(setGlobalConfig) return listenForGlobalConfig(setGlobalConfig)
}, []) }, [setGlobalConfig])
return globalConfig 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>
@ -103,6 +114,7 @@ export const usePersistentState = <T>(
if (key != null && store != null) { if (key != null && store != null) {
store.set(key, state) store.set(key, state)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, state]) }, [key, state])
if (store?.readsUrl) { if (store?.readsUrl) {
@ -116,6 +128,7 @@ export const usePersistentState = <T>(
const savedValue = key != null ? store.get(key) : undefined const savedValue = key != null ? store.get(key) : undefined
setState(savedValue ?? initial) setState(savedValue ?? initial)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]) }, [router.isReady])
} }

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