Compare commits

..

5 Commits
main ... alvea

Author SHA1 Message Date
Austin Chen
1c0dce0aa4 Fill in cloudRunId 2022-10-12 13:58:54 -07:00
Austin Chen
f979785d27 Link to the logo 2022-10-12 12:29:43 -07:00
Austin Chen
5c2ef26943 Update firestore.indexes.json 2022-10-12 12:29:39 -07:00
Austin Chen
dcc1379633 Add alvea logo 2022-10-12 12:29:36 -07:00
Austin Chen
7141e4336e Set up Alvea instance 2022-10-12 12:10:51 -07:00
133 changed files with 1759 additions and 2344 deletions

View File

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

View File

@ -595,8 +595,7 @@ 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: description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
'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,7 +10,6 @@ export type AnyOutcomeType =
| PseudoNumeric | PseudoNumeric
| FreeResponse | FreeResponse
| Numeric | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)

25
common/envs/alvea.ts Normal file
View File

@ -0,0 +1,25 @@
import { EnvConfig, PROD_CONFIG } from './prod'
export const ALVEA_CONFIG: EnvConfig = {
domain: 'alvea.manifold.markets',
firebaseConfig: {
apiKey: 'AIzaSyDT5D8IUGRfT9a1bNYb-1b-RGm1JOHoW7Y',
authDomain: 'alvea-manifold.firebaseapp.com',
projectId: 'alvea-manifold',
storageBucket: 'alvea-manifold.appspot.com',
messagingSenderId: '854070403258',
appId: '1:854070403258:web:b8b5f303bc97d010882283',
measurementId: 'G-QHP8V3BM54',
},
cloudRunId: '2iftcb75eq',
cloudRunRegion: 'uc',
adminEmails: [...PROD_CONFIG.adminEmails],
whitelistEmail: '@alveavax.com',
moneyMoniker: 'A$',
visibility: 'PRIVATE',
// faviconPath: '/theoremone/logo.ico',
navbarLogoPath: '/alvea/alvea-logo.svg',
newQuestionPlaceholders: [
'Will we have at least 5 new team members by the end of this quarter?',
],
}

View File

@ -1,4 +1,5 @@
import { escapeRegExp } from 'lodash' import { escapeRegExp } from 'lodash'
import { ALVEA_CONFIG } from './alvea'
import { DEV_CONFIG } from './dev' import { DEV_CONFIG } from './dev'
import { EnvConfig, PROD_CONFIG } from './prod' import { EnvConfig, PROD_CONFIG } from './prod'
import { THEOREMONE_CONFIG } from './theoremone' import { THEOREMONE_CONFIG } from './theoremone'
@ -9,6 +10,7 @@ const CONFIGS: { [env: string]: EnvConfig } = {
PROD: PROD_CONFIG, PROD: PROD_CONFIG,
DEV: DEV_CONFIG, DEV: DEV_CONFIG,
THEOREMONE: THEOREMONE_CONFIG, THEOREMONE: THEOREMONE_CONFIG,
ALVEA: ALVEA_CONFIG,
} }
export const ENV_CONFIG = CONFIGS[ENV] export const ENV_CONFIG = CONFIGS[ENV]

View File

@ -16,6 +16,7 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba', cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets', // this is Phil's deployment
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.manifold.markets', twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
cloudRunId: 'nggbo3neva', cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
adminEmails: [ adminEmails: [

View File

@ -1,9 +1,8 @@
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' | 'post' type: 'contract'
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,13 +8,11 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tiptap/core": "2.0.0-beta.199", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.199", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.199", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.199", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/html": "2.0.0-beta.199", "@tiptap/starter-kit": "2.0.0-beta.191",
"@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,8 +56,7 @@ export const getLiquidityPoolPayouts = (
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool, subsidyPool } = contract const { pool, subsidyPool } = contract
const finalPool = pool[outcome] + (subsidyPool ?? 0) const finalPool = pool[outcome] + subsidyPool
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(liquidities)
@ -96,8 +95,7 @@ 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 ?? 0) const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(liquidities)

View File

@ -13,9 +13,6 @@ 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,5 +1,4 @@
import { generateText, JSONContent, Node } from '@tiptap/core' import { generateText, JSONContent } 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'
@ -52,26 +51,6 @@ 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,
@ -99,8 +78,6 @@ 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] }),
] ]
@ -109,7 +86,3 @@ 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,17 +680,6 @@ $ 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

@ -100,20 +100,6 @@
} }
] ]
}, },
{
"collectionGroup": "comments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{ {
"collectionGroup": "comments", "collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",
@ -142,6 +128,20 @@
} }
] ]
}, },
{
"collectionGroup": "comments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -188,6 +188,56 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "resolution",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "uniqueBettorCount",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "groupSlug",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -206,6 +256,34 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "groupSlugs",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "groupSlugs",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "probChanges.day",
"order": "ASCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -274,6 +352,24 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "contracts", "collectionGroup": "contracts",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -474,6 +570,62 @@
} }
] ]
}, },
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "uniqueBettorIds",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "uniqueBettorIds",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "probChanges.day",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "uniqueBettorIds",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "probChanges.day",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "groups",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "anyoneCanJoin",
"order": "ASCENDING"
},
{
"fieldPath": "totalMembers",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "manalinks", "collectionGroup": "manalinks",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -530,6 +682,34 @@
} }
] ]
}, },
{
"collectionGroup": "txns",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "toId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "txns",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "toId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{ {
"collectionGroup": "txns", "collectionGroup": "txns",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
@ -778,6 +958,66 @@
} }
] ]
}, },
{
"collectionGroup": "groupContracts",
"fieldPath": "contractId",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "groupMembers",
"fieldPath": "userId",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION_GROUP"
}
]
},
{ {
"collectionGroup": "portfolioHistory", "collectionGroup": "portfolioHistory",
"fieldPath": "timestamp", "fieldPath": "timestamp",

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', 'flaggedByUsernames']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
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

@ -1,3 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions # This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=DEV NEXT_PUBLIC_FIREBASE_ENV=ALVEA

View File

@ -1,3 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions # This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=PROD NEXT_PUBLIC_FIREBASE_ENV=ALVEA

View File

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

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 -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil 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 -m 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 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,13 +26,11 @@
"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.199", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.199", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.199", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.199", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/html": "2.0.0-beta.199", "@tiptap/starter-kit": "2.0.0-beta.191",
"@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",
@ -40,7 +38,6 @@
"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",
@ -48,7 +45,6 @@
}, },
"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

@ -1,105 +0,0 @@
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,7 +197,6 @@ 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)
} }
@ -206,12 +205,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types reason: notification_reason_types
) => { ) => {
if ( if (!stillFollowingContract(userId) || sourceUser.id == userId) return
(!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,7 +103,6 @@ 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,7 +65,6 @@ 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'
@ -95,7 +94,6 @@ 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)
@ -132,7 +130,6 @@ 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, uniqBy } from 'lodash' import { mapValues, groupBy, sumBy } from 'lodash'
import { import {
Contract, Contract,
@ -15,14 +15,14 @@ import {
getValues, getValues,
isProd, isProd,
log, log,
payUsers, payUser,
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,7 +36,6 @@ 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(),
@ -90,10 +89,13 @@ 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 } = contract const { creatorId, closeTime } = contract
const firebaseUser = await admin.auth().getUser(auth.uid) const firebaseUser = await admin.auth().getUser(auth.uid)
const resolutionParams = getResolutionParams(contract, req.body) const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
if ( if (
creatorId !== auth.uid && creatorId !== auth.uid &&
@ -107,16 +109,6 @@ 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
@ -139,12 +131,8 @@ export const resolveMarket = async (
(doc) => doc.data() as LiquidityProvision (doc) => doc.data() as LiquidityProvision
) )
const { const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
payouts: traderPayouts, getPayouts(
creatorPayout,
liquidityPayouts,
collectedFees,
} = getPayouts(
outcome, outcome,
contract, contract,
bets, bets,
@ -168,47 +156,33 @@ export const resolveMarket = async (
subsidyPool: 0, subsidyPool: 0,
} }
const openBets = bets.filter((b) => !b.isSold && !b.sale)
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())
console.log(
'trader payouts:',
traderPayouts,
'creator payout:',
creatorPayout,
'liquidity payout:',
liquidityPayouts,
'loan payouts:',
loanPayouts
)
const userCount = uniqBy(payouts, 'userId').length
const contractDoc = firestore.doc(`contracts/${contractId}`)
if (userCount <= 499) {
await firestore.runTransaction(async (transaction) => {
payUsers(transaction, payouts)
transaction.update(contractDoc, updatedContract)
})
} else {
await payUsersMultipleTransactions(payouts)
await contractDoc.update(updatedContract) await contractDoc.update(updatedContract)
}
console.log('contract ', contractId, 'resolved to:', outcome) console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
if (!isProd())
console.log(
'payouts:',
payouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
)
if (creatorPayout)
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
await processPayouts(liquidityPayouts, true)
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract)) await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userInvestments = mapValues( const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
@ -235,6 +209,18 @@ export const resolveMarket = async (
) )
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) {
@ -301,8 +287,6 @@ 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

@ -1,24 +0,0 @@
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

@ -1,59 +0,0 @@
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,7 +19,6 @@ 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'
@ -54,7 +53,6 @@ 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,8 +1,7 @@
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'
@ -129,29 +128,38 @@ 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,
balanceDelta: number, delta: number,
depositDelta: number isDeposit = false
) => { ) => {
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
// Note: Balance is allowed to go negative. const newUserBalance = user.balance + delta
transaction.update(userDoc, {
balance: FieldValue.increment(balanceDelta), // if (newUserBalance < 0)
totalDeposits: FieldValue.increment(depositDelta), // throw new Error(
// `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 firestore.runTransaction(async (transaction) => { return updateUserBalance(userId, payout, isDeposit)
updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0)
})
} }
export const chargeUser = ( export const chargeUser = (
@ -162,67 +170,7 @@ 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 payUser(userId, -charge, isAnte) return updateUserBalance(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,9 +22,8 @@ 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': 'warn', 'unused-imports/no-unused-imports': 'error',
}, },
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="flex"> <div className="modal-action">
<Button color="gray-white" onClick={() => setOpen(false)}> <Button color="gray-white" onClick={() => setOpen(false)}>
Back Back
</Button> </Button>

View File

@ -7,8 +7,6 @@ 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
@ -42,13 +40,18 @@ export function AmountInput(props: {
return ( return (
<> <>
<Col className={clsx('relative', className)}> <Col className={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('w-24 pl-9 !text-base md:w-auto', inputClassName)} className={clsx(
'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]*"
@ -56,14 +59,13 @@ 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 -bottom-5 whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
<> <>
Not enough funds. Not enough funds.
@ -147,7 +149,7 @@ export function BuyAmountInput(props: {
return ( return (
<> <>
<Row className="items-center gap-4"> <Row className="gap-4">
<AmountInput <AmountInput
amount={amount} amount={amount}
onChange={onAmountChange} onChange={onAmountChange}
@ -159,23 +161,14 @@ export function BuyAmountInput(props: {
inputRef={inputRef} inputRef={inputRef}
/> />
{showSlider && ( {showSlider && (
<Slider <input
min={0} type="range"
max={205} min="0"
max="205"
value={getRaw(amount ?? 0)} value={getRaw(amount ?? 0)}
onChange={(value) => onAmountChange(parseRaw(value as number))} onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
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" className="range range-lg only-thumb my-auto align-middle xl:hidden"
railStyle={{ height: 16, top: 0, left: 0 }} step="5"
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,10 +126,7 @@ export function AnswerBetPanel(props: {
</div> </div>
{!isModal && ( {!isModal && (
<button <button className="btn-ghost btn-circle" onClick={closePanel}>
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="flex flex-col py-1"> <div className="form-control py-1">
<label className="cursor-pointer gap-3 px-1 py-2"> <label className="label cursor-pointer gap-3">
<span className="">Choose this answer</span> <span className="">Choose this answer</span>
{showChoice === 'radio' && ( {showChoice === 'radio' && (
<input <input

View File

@ -10,7 +10,6 @@ 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
@ -110,14 +109,14 @@ export function AnswerResolvePanel(props: {
)} )}
> >
{resolveOption && ( {resolveOption && (
<Button <button
color="gray-white" className="btn btn-ghost"
onClick={() => { onClick={() => {
setResolveOption(undefined) setResolveOption(undefined)
}} }}
> >
Clear Clear
</Button> </button>
)} )}
<ResolveConfirmationButton <ResolveConfirmationButton

View File

@ -27,13 +27,6 @@ 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
@ -114,8 +107,8 @@ export function AnswersPanel(props: {
? 'checkbox' ? 'checkbox'
: undefined : undefined
const answersArray = useChartAnswers(contract).map( const colorSortedAnswer = useChartAnswers(contract).map(
(answer, _index) => answer.text (value, _index) => value.text
) )
return ( return (
@ -146,8 +139,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 && (
@ -192,14 +185,18 @@ export function AnswersPanel(props: {
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
color: string colorIndex: number | undefined
onAnswerCommentClick: (answer: Answer) => void onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const { answer, contract, onAnswerCommentClick, color } = props const { answer, contract, colorIndex, onAnswerCommentClick } = props
const { username, avatarUrl, text } = answer const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const color =
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 (
@ -220,7 +217,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}90 ${colorWidth}%, #FBFBFF ${colorWidth}%)`, background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}} }}
> >
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3"> <Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
@ -230,7 +227,10 @@ function OpenAnswer(props: {
username={username} username={username}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
/> />
<Linkify className="text-md whitespace-pre-line" text={text} /> <Linkify
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,15 +197,17 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</> </>
)} )}
{user ? ( {user ? (
<Button <button
color="green" className={clsx(
size="lg" 'btn mt-2',
loading={isSubmitting} canSubmit ? 'btn-outline' : 'btn-disabled',
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,6 +5,7 @@ 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'
@ -36,17 +37,10 @@ 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 <Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
className={clsx('my-auto items-center gap-0.5', !canUp ? '-ml-6' : '')} <TextButton className={'font-bold'} onClick={submit}>
>
<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)}
</button> </TextButton>
</Row> </Row>
) )
} }

View File

@ -92,7 +92,10 @@ export function BetInline(props: {
/> />
<BuyAmountInput <BuyAmountInput
className="-mb-4" className="-mb-4"
inputClassName="w-20 !text-base" inputClassName={clsx(
'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 mb-4 items-center gap-4"> <Row className="mt-1 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="text-sm text-gray-500"> <div className="relative ml-1 text-sm text-gray-500">
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -641,11 +641,10 @@ 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="text-sm text-gray-500"> <div className="ml-1 text-sm text-gray-500">
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -653,7 +652,6 @@ function LimitOrderPanel(props: {
prob={highLimitProb} prob={highLimitProb}
setProb={setHighLimitProb} setProb={setHighLimitProb}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
placeholder="90"
/> />
</Col> </Col>
</Row> </Row>
@ -787,11 +785,11 @@ function LimitOrderPanel(props: {
{user && ( {user && (
<Button <Button
size="xl" size="xl"
disabled={betDisabled} disabled={betDisabled ? true : false}
color={'indigo'} color={'indigo'}
loading={isSubmitting} loading={isSubmitting}
className="flex-1" className="flex-1"
onClick={submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
@ -982,11 +980,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-gray-700">{formatMoney(saleValue)}</span> <span className="text-neutral">{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-gray-700">{formatMoney(profit)}</span> <span className="text-neutral">{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">
@ -1002,11 +1000,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-gray-700">{formatMoney(-loanPaid)}</span> <span className="text-neutral">{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-gray-700">{formatMoney(netProceeds)}</span> <span className="text-neutral">{formatMoney(netProceeds)}</span>
</Row> </Row>
</> </>
)} )}

View File

@ -25,8 +25,10 @@ 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, hasShares } = const { profitPercent, payout, profit, invested } = getContractBetMetrics(
getContractBetMetrics(contract, bets) contract,
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) =>
@ -37,7 +39,6 @@ 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
@ -59,9 +60,7 @@ 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 <InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." />
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,8 +52,6 @@ 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'
@ -202,19 +200,21 @@ 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">Active</option> <option value="open">Open</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> <table className="table-zebra table-compact table w-full text-gray-500">
<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-gray-700"> <td className="text-neutral">
{isYourBet && {isYourBet &&
!isCPMM && !isCPMM &&
!isResolved && !isResolved &&

View File

@ -13,6 +13,7 @@ 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',
@ -26,7 +27,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 ring-inset shadow-sm transition-colors disabled:cursor-not-allowed', 'font-md inline-flex items-center justify-center rounded-md border border-transparent 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',
@ -41,11 +42,13 @@ 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' &&
'ring-2 ring-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 hover:text-white disabled:opacity-50', 'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' && color === 'gradient' &&
'disabled:bg-greyscale-2 bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', 'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 shadow-none disabled:opacity-50' 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
) )
} }
@ -82,39 +85,3 @@ 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={
'w-full max-w-xs items-center justify-between gap-4 pr-3' 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
} }
> >
<AmountInput <AmountInput

View File

@ -8,12 +8,10 @@ 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'
@ -83,30 +81,20 @@ export function AnswerCommentInput(props: {
contract: Contract<AnyContractType> contract: Contract<AnyContractType>
answerResponse: Answer answerResponse: Answer
onCancelAnswerResponse?: () => void onCancelAnswerResponse?: () => void
answersArray: string[]
}) { }) {
const { contract, answerResponse, onCancelAnswerResponse, answersArray } = const { contract, answerResponse, onCancelAnswerResponse } = props
props
const replyTo = { const replyTo = {
id: answerResponse.id, id: answerResponse.id,
username: answerResponse.username, username: answerResponse.username,
} }
const color = getAnswerColor(answerResponse, answersArray)
return ( return (
<> <>
<Col> <CommentsAnswer answer={answerResponse} contract={contract} />
<Row className="relative"> <Row>
<div className="absolute -bottom-1 left-1.5"> <div className="ml-1">
<Curve size={32} strokeWidth={1} color="#D8D8EB" /> <Curve size={28} 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}
@ -119,7 +107,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>
</Col> </Row>
</> </>
) )
} }

View File

@ -42,7 +42,6 @@ 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' },
@ -438,7 +437,7 @@ function ContractSearchControls(props: {
} }
return ( return (
<Col className={clsx('bg-greyscale-1 top-0 z-20 gap-3 pb-3', className)}> <Col className={clsx('bg-base-200 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"
@ -544,7 +543,8 @@ 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,9 +552,10 @@ 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)}
> >
@ -563,7 +564,7 @@ export function SearchFilters(props: {
{option.label} {option.label}
</option> </option>
))} ))}
</Select> </select>
)} )}
</div> </div>
) )

View File

@ -4,6 +4,7 @@ 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'
@ -63,7 +64,7 @@ export function CommentBountyDialog(props: {
<Row className={'items-center gap-2'}> <Row className={'items-center gap-2'}>
<Button <Button
className="ml-2" className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit} onClick={submit}
disabled={isLoading} disabled={isLoading}
color={'blue'} color={'blue'}

View File

@ -1,11 +1,7 @@
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 { import { formatLargeNumber, formatPercent } from 'common/util/format'
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 {
@ -39,10 +35,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 { ProbOrNumericChange } from './prob-change-table' import { ProbChange } 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
@ -400,25 +396,14 @@ 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 binaryOutcome = const outcome =
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
@ -433,7 +418,7 @@ export function ContractCardProbChange(props: {
> >
<span className="line-clamp-3">{contract.question}</span> <span className="line-clamp-3">{contract.question}</span>
</SiteLink> </SiteLink>
<ProbOrNumericChange className="py-2 pr-4" contract={contract} /> <ProbChange className="py-2 pr-4" contract={contract} />
</Row> </Row>
{showPosition && metrics && metrics.hasShares && ( {showPosition && metrics && metrics.hasShares && (
<Row <Row
@ -441,11 +426,19 @@ 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-400"> <Row className="gap-1 text-gray-700">
You: {formatWithCommas(metrics.totalShares[binaryOutcome])}{' '} <div className="text-gray-500">Position</div>
{binaryOutcome === 'YES' ? yesOutcomeLabel : noOutcomeLabel} shares {Math.floor(metrics.totalShares[outcome])} {outcome}
<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,11 +45,13 @@ 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() {
@ -64,8 +66,10 @@ 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,7 +3,6 @@ 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'
@ -35,7 +34,6 @@ 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,
@ -51,41 +49,32 @@ export function MiscDetails(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
}) { }) {
const { contract, showTime, hideGroupLink } = props const { contract, showTime, hideGroupLink } = props
const { const { volume, closeTime, isResolved, createdTime, resolutionTime } =
closeTime, contract
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">
{isClient && showTime === 'close-date' ? ( {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>
) : isClient && showTime === 'resolve-date' && resolutionTime ? ( ) : showTime === 'resolve-date' && resolutionTime !== undefined ? (
<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)} {fromNow(resolutionTime || 0)}
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? ( ) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge /> <BountiedContractBadge />
) : !isNew || (uniqueBettorCount ?? 0) > 1 ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
<UserGroupIcon className="mr-1 h-4 w-4" />
{uniqueBettorCount} trader{uniqueBettorCount !== 1 ? 's' : ''}
</Row>
) : ( ) : (
<NewContractBadge /> <NewContractBadge />
)} )}
@ -401,7 +390,6 @@ 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()
@ -464,7 +452,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={isClient ? Date.now() : undefined} min={Date.now()}
value={closeDate} value={closeDate}
/> />
<Input <Input
@ -491,18 +479,14 @@ function EditableCloseDate(props: {
</Col> </Col>
</Modal> </Modal>
<DateTimeTooltip <DateTimeTooltip
text={ text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
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 && isClient ? ( {isSameDay ? (
<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,10 +19,11 @@ 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 { IconButton } from '../button' import { Button } from '../button'
import { AddLiquidityButton } from './add-liquidity-button' import { AddLiquidityButton } from './add-liquidity-button'
import { Tooltip } from '../tooltip'
import { Table } from '../table' export const contractDetailsButtonClassName =
'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
@ -83,23 +84,23 @@ export function ContractInfoDialog(props: {
return ( return (
<> <>
<Tooltip text="Market details" placement="bottom" noTap noFade> <Button
<IconButton size="sm"
size="2xs" color="gray-white"
className={clsx(className)} className={clsx(contractDetailsButtonClassName, 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"
/> />
</IconButton> </Button>
<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> <table className="table-compact table-zebra table w-full text-gray-500">
<tbody> <tbody>
<tr> <tr>
<td>Type</td> <td>Type</td>
@ -185,8 +186,7 @@ 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' && : mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC'
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,7 +249,6 @@ export function ContractInfoDialog(props: {
</Row> </Row>
</Col> </Col>
</Modal> </Modal>
</Tooltip>
</> </>
) )
} }

View File

@ -6,19 +6,17 @@ 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={isClient ? tooltipLabel(contract) : undefined} title={tooltipLabel(contract)}
> >
<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-1 py-2 hover:bg-gray-300', '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',
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 { FreeResponseComments } from '../feed/feed-answer-comment-group' import { CommentsAnswer } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy, sum } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { AnyContractType, Contract } from 'common/contract' import { 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,7 +35,9 @@ 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
@ -137,27 +139,95 @@ 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}
comments={comments} {answerResponse && (
contract={contract} <AnswerCommentInput
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}
tips={tips}
/> />
)} )}
{contract.outcomeType !== 'FREE_RESPONSE' && {topLevelComments.map((parent) => {
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 <></>
}
return (
<>
<Row className="gap-2">
<CommentsAnswer answer={answer} contract={contract} />
</Row>
<Row>
<div className="ml-1">
<Curve size={28} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="w-full pt-1">
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
</Row>
</>
)
})}
</>
)
} else {
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}
contract={contract} contract={contract}
@ -171,6 +241,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
))} ))}
</> </>
) )
}
}) })
const BetsTabContent = memo(function BetsTabContent(props: { const BetsTabContent = memo(function BetsTabContent(props: {
@ -239,33 +310,3 @@ 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 { IconButton } from 'web/components/button' import { Button } 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 { LikeItemButton } from 'web/components/contract/like-item-button' import { LikeMarketButton } from 'web/components/contract/like-market-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,14 +16,15 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
return ( return (
<Row className="gap-1"> <Row>
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
<LikeItemButton item={contract} user={user} itemType={'contract'} /> <LikeMarketButton contract={contract} user={user} />
<Tooltip text="Share" placement="bottom" noTap noFade> <Tooltip text="Share" placement="bottom" noTap noFade>
<IconButton <Button
size="2xs" size="sm"
color="gray-white"
className={'flex'} className={'flex'}
onClick={() => setShareOpen(true)} onClick={() => setShareOpen(true)}
> >
@ -34,7 +35,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
contract={contract} contract={contract}
user={user} user={user}
/> />
</IconButton> </Button>
</Tooltip> </Tooltip>
<ContractInfoDialog contract={contract} user={user} /> <ContractInfoDialog contract={contract} user={user} />

View File

@ -1,71 +0,0 @@
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

@ -0,0 +1,56 @@
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,14 +7,12 @@ 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, maxRows } = props const { contracts, metrics } = 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
@ -28,7 +26,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),
@ -38,7 +36,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>
@ -120,7 +118,7 @@ export function ProbChangeTable(props: {
) )
} }
export function ProbOrNumericChange(props: { export function ProbChange(props: {
contract: CPMMContract contract: CPMMContract
className?: string className?: string
}) { }) {
@ -129,17 +127,13 @@ export function ProbOrNumericChange(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">
{number ? number : formatPercent(Math.round(100 * prob) / 100)} {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,84 +14,55 @@ export function TipButton(props: {
isCompact?: boolean isCompact?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
const { tipAmount, totalTipped, userTipped, onClick, disabled } = props const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
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
? `Total tips ${formatMoney(totalTipped)}` ? `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}
className={clsx(
'px-2 py-1 text-xs', //2xs button
'text-greyscale-5 transition-transform disabled:cursor-not-allowed',
!disabled ? 'hover:text-greyscale-6' : ''
)}
onMouseOver={() => {
if (!disabled) {
setHover(true)
}
}}
onMouseLeave={() => setHover(false)}
> >
<Col className={clsx('relative')}> <Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5',
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-teal-500 text-teal-500' : ''
)}
/>
{totalTipped > 0 && (
<div <div
className={clsx( className={clsx(
'absolute transition-all', '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',
hover ? 'left-[6px] -top-[9px]' : 'left-[8px] -top-[10px]' tipDisplay.length > 2
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)} )}
> >
<Coin {tipDisplay}
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,6 +22,7 @@ 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 =
@ -55,8 +56,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="flex w-full flex-col"> <div className="form-control w-full">
<label className="px-1 py-2"> <label className="label">
<span className="mb-1"> <span className="mb-1">
Title<span className={'text-red-700'}> *</span> Title<span className={'text-red-700'}> *</span>
</span> </span>
@ -69,7 +70,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="px-1 py-2"> <label className="label">
<span className="mb-1"> <span className="mb-1">
Subtitle<span className={'text-red-700'}> *</span> Subtitle<span className={'text-red-700'}> *</span>
</span> </span>
@ -82,7 +83,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="px-1 py-2"> <label className="label">
<span className="mb-1"> <span className="mb-1">
Content<span className={'text-red-700'}> *</span> Content<span className={'text-red-700'}> *</span>
</span> </span>
@ -92,10 +93,9 @@ export function CreatePost(props: { group?: Group }) {
<Button <Button
type="submit" type="submit"
color="green"
size="xl"
loading={isSubmitting} loading={isSubmitting}
disabled={!isValid || upload.isLoading} size="xl"
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => { onClick={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await savePost(title) await savePost(title)

View File

@ -22,7 +22,6 @@ 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'
@ -53,7 +52,7 @@ import { debounce } from 'lodash'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
class: 'max-h-60 hover:max-h-[120rem] transition-all', class: 'max-h-60',
}, },
}) })
@ -82,7 +81,6 @@ export const editorExtensions = (simple = false): Extensions => [
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent, GridComponent,
StaticReactEmbedComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({
@ -92,17 +90,18 @@ 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 prose-sm' 'font-light prose-a:font-light prose-blockquote:font-light'
) )
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, simple, key } = props const { placeholder, max, defaultValue, disabled, simple, key } = props
const [content, saveContent] = usePersistentState<JSONContent | undefined>( const [content, saveContent] = usePersistentState<JSONContent | undefined>(
undefined, undefined,
@ -170,6 +169,10 @@ export function useTextEditor(props: {
}, },
}) })
useEffect(() => {
editor?.setEditable(!disabled)
}, [editor, disabled])
return { editor, upload } return { editor, upload }
} }
@ -260,8 +263,7 @@ 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">
{/* matches input styling */} <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">
<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 */}
@ -365,7 +367,6 @@ export function RichContent(props: {
DisplayMention, DisplayMention,
DisplayContractMention, DisplayContractMention,
GridComponent, GridComponent,
StaticReactEmbedComponent,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({

View File

@ -14,7 +14,6 @@ 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,10 +120,7 @@ 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"> <div className="pt-2 text-sm text-gray-400">Modifiers: {MODIFIERS}</div>
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,7 +14,6 @@ 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

@ -1,44 +0,0 @@
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(
'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]', 'textarea textarea-bordered resize-none text-[16px] md:text-[14px]',
className className
)} )}
{...rest} {...rest}

View File

@ -5,7 +5,6 @@ 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
@ -15,7 +14,6 @@ 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(
@ -38,7 +36,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'
} }
> >
{isClient && fromNow(createdTime)} {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,60 +1,32 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { import { Contract } from 'common/contract'
Contract, import React, { useEffect, useRef } from 'react'
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: { export function CommentsAnswer(props: { answer: Answer; contract: Contract }) {
answer: Answer const { answer, contract } = props
contract: Contract const { username, avatarUrl, name, text } = answer
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 { isReady, asPath } = useRouter() const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const [highlighted, setHighlighted] = useState(false)
const answerRef = useRef<HTMLDivElement>(null) const answerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (isReady && asPath.endsWith(`#${answerElementId}`)) { if (highlighted && answerRef.current != null) {
setHighlighted(true)
}
}, [isReady, asPath, answerElementId])
useEffect(() => {
if (highlighted && answerRef.current) {
answerRef.current.scrollIntoView(true) answerRef.current.scrollIntoView(true)
} }
}, [highlighted]) }, [highlighted])
return ( return (
<Row> <Col className="bg-greyscale-2 w-fit gap-1 rounded-t-xl rounded-bl-xl py-2 px-4">
<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">
<div className="text-greyscale-4 text-xs"> <Avatar username={username} avatarUrl={avatarUrl} size="xxs" />
<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}
@ -66,89 +38,5 @@ export function CommentsAnswer(props: {
</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 { IconButton } from '../button' import { Button } 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(true) const [seeReplies, setSeeReplies] = useState(false)
const user = useUser() const user = useUser()
const onSubmitComment = useEvent(() => setReplyTo(undefined)) const onSubmitComment = useEvent(() => setReplyTo(undefined))
@ -154,43 +154,33 @@ export function ParentFeedComment(props: {
numComments={numComments} numComments={numComments}
onClick={onSeeReplyClick} onClick={onSeeReplyClick}
/> />
<CommentActions <Row className="grow justify-end gap-2">
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 && (
<IconButton size={'xs'} onClick={() => onReplyClick(comment)}> <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" /> <ReplyIcon className="h-5 w-5" />
</IconButton> </Button>
)} )}
{showTip && ( {showTip && (
<Tipper comment={comment} myTip={myTip ?? 0} totalTip={totalTip ?? 0} /> <Tipper
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)} )}
{(contract.openCommentBounties ?? 0) > 0 && ( {(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} /> <AwardBountyButton comment={comment} contract={contract} />
)} )}
</Row> </Row>
</Row>
</Col>
</Row>
) )
} }
@ -243,14 +233,30 @@ export const FeedComment = memo(function FeedComment(props: {
content={content || text} content={content || text}
smallImage smallImage
/> />
<CommentActions <Row className="grow justify-end gap-2">
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}
showTip={showTip} myTip={myTip ?? 0}
myTip={myTip} totalTip={totalTip ?? 0}
totalTip={totalTip}
contract={contract}
/> />
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</Col> </Col>
</Row> </Row>
) )

View File

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

View File

@ -1,4 +1,4 @@
import { IconButton } from 'web/components/button' import { Button } from 'web/components/button'
import { import {
Contract, Contract,
followContract, followContract,
@ -33,8 +33,9 @@ export const FollowMarketButton = (props: {
noTap noTap
noFade noFade
> >
<IconButton <Button
size="2xs" size={'sm'}
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)) {
@ -64,12 +65,18 @@ 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 className={clsx('h-5 w-5')} aria-hidden="true" /> <EyeOffIcon
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 className={clsx('h-5 w-5')} aria-hidden="true" /> <EyeIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */} {/* Watch */}
</Col> </Col>
)} )}
@ -80,7 +87,7 @@ export const FollowMarketButton = (props: {
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched' followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`} } a question!`}
/> />
</IconButton> </Button>
</Tooltip> </Tooltip>
) )
} }

View File

@ -1,4 +1,5 @@
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'
@ -10,6 +11,7 @@ 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
@ -38,6 +40,37 @@ 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="flex w-full flex-col"> <div className="form-control w-full">
<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,7 +11,6 @@ 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
@ -41,18 +40,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)}>
<Button <div
size="sm" className={clsx(
color="gray-white" 'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700'
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
</Button> </div>
<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="flex w-full flex-col"> <div className="form-control w-full">
<label className="px-1 py-2"> <label className="label">
<span className="mb-1">Group name</span> <span className="mb-1">Group name</span>
</label> </label>
@ -66,8 +65,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<Spacer h={4} /> <Spacer h={4} />
<div className="flex w-full flex-col"> <div className="form-control w-full">
<label className="px-1 py-2"> <label className="label">
<span className="mb-0">Add members</span> <span className="mb-0">Add members</span>
</label> </label>
<FilterSelectUsers <FilterSelectUsers
@ -77,10 +76,9 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
/> />
</div> </div>
<div className="flex"> <div className="modal-action">
<Button <label
color="red" htmlFor="edit"
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)
@ -88,24 +86,30 @@ 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
</Button> </label>
<Button <label
color="gray-white" htmlFor="edit"
size="xs" className={'btn'}
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
Cancel Cancel
</Button> </label>
<Button <label
color="green" className={clsx(
disabled={saveDisabled} 'btn',
loading={isSubmitting} saveDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting && 'loading'
)}
htmlFor="edit"
onClick={onSubmit} onClick={onSubmit}
> >
Save Save
</Button> </label>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -33,9 +33,11 @@ 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() {
@ -74,8 +76,10 @@ 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,7 +116,10 @@ export function GroupPosts(props: { posts: Post[]; group: Group }) {
</Col> </Col>
<Col> <Col>
{user && ( {user && (
<Button onClick={() => setShowCreatePost(!showCreatePost)}> <Button
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post Add a Post
</Button> </Button>
)} )}
@ -264,7 +267,7 @@ export function PinnedItems(props: {
</div> </div>
)} )}
{pinned.map((element, index) => ( {pinned.map((element, index) => (
<div className="relative mb-4" key={element.key}> <div className="relative mb-4">
{element} {element}
{editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />} {editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
@ -377,7 +380,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-gray-700" className="text-neutral"
name={creator.name} name={creator.name}
username={creator.username} username={creator.username}
/> />
@ -429,7 +432,7 @@ export function GroupAbout(props: {
<CopyLinkButton <CopyLinkButton
url={shareUrl} url={shareUrl}
tracking="copy group share link" tracking="copy group share link"
buttonClassName="rounded-l-none" buttonClassName="btn-md 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="flex flex-col items-start"> <div className="form-control 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="justify-start gap-2 px-1 py-2 text-base"> <Combobox.Label className="label justify-start gap-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="sm" size="xs"
color="gray-outline" color="gray-white"
className={className} className={`${className} border-greyscale-4 border !border-solid`}
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="sm" size="xs"
color="indigo" color="blue"
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 = ( export const Input = (props: JSX.IntrinsicElements['input']) => {
props: { error?: boolean } & JSX.IntrinsicElements['input'] const { className, ...rest } = props
) => {
const { error, className, ...rest } = props
return ( return (
<input <input
className={clsx( className={clsx('input input-bordered text-base md:text-sm', className)}
'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,7 +2,6 @@ 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 {
@ -32,9 +31,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> <table className="table-zebra table-compact table w-full text-gray-500">
<thead> <thead>
<tr> <tr className="p-2">
<th>#</th> <th>#</th>
<th>Name</th> <th>Name</th>
{columns.map((column) => ( {columns.map((column) => (
@ -60,7 +59,7 @@ export function Leaderboard<T extends LeaderboardEntry>(props: {
</tr> </tr>
))} ))}
</tbody> </tbody>
</Table> </table>
</div> </div>
)} )}
</div> </div>

View File

@ -14,7 +14,6 @@ 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: {
@ -75,7 +74,7 @@ export function LimitOrderTable(props: {
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
return ( return (
<Table className="rounded"> <table className="table-compact table w-full rounded text-gray-500">
<thead> <thead>
<tr> <tr>
{!isYou && <th></th>} {!isYou && <th></th>}
@ -90,7 +89,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 rounded-b-lg bg-white px-4 py-2 align-middle text-lg"> <Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg">
<div <div
className={clsx( className={clsx(
'my-auto mb-1 w-full', 'my-auto mb-1 w-full',
@ -133,23 +133,32 @@ export function ManalinkCardFromView(props: {
{formatMoney(amount)} {formatMoney(amount)}
</div> </div>
<IconButton size="2xs" onClick={() => (window.location.href = qrUrl)}> <button
onClick={() => (window.location.href = qrUrl)}
className={clsx(contractDetailsButtonClassName)}
>
<QrcodeIcon className="h-6 w-6" /> <QrcodeIcon className="h-6 w-6" />
</IconButton> </button>
<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)}
/> />
<IconButton <button
size="xs"
onClick={() => setShowDetails(!showDetails)} onClick={() => setShowDetails(!showDetails)}
className={clsx( className={clsx(
showDetails ? ' text-indigo-600 hover:text-indigo-700' : '' contractDetailsButtonClassName,
showDetails
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
: ''
)} )}
> >
<DotsHorizontalIcon className="h-5 w-5" /> <DotsHorizontalIcon className="h-[24px] w-5" />
</IconButton> </button>
</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,7 +14,6 @@ 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
@ -116,8 +115,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="flex flex-auto flex-col"> <div className="form-control flex-auto">
<label className="px-1 py-2">Amount</label> <label className="label">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$
@ -136,8 +135,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="flex w-full flex-col md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="px-1 py-2">Uses</label> <label className="label">Uses</label>
<Input <Input
type="number" type="number"
min="1" min="1"
@ -149,9 +148,10 @@ function CreateManalinkForm(props: {
} }
/> />
</div> </div>
<div className="flex w-full flex-col md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="px-1 py-2">Expires in</label> <label className="label">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="flex w-full flex-col"> <div className="form-control w-full">
<label className="px-1 py-2">Message</label> <label className="label">Message</label>
<ExpandingInput <ExpandingInput
placeholder={defaultMessage} placeholder={defaultMessage}
maxLength={200} maxLength={200}

View File

@ -1,212 +0,0 @@
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,8 +32,13 @@ export function NumberInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group">
<Input <Input
className={clsx('max-w-[200px] !text-lg', inputClassName)} className={clsx(
'max-w-[200px] !text-lg',
error && 'input-error',
inputClassName
)}
ref={inputRef} ref={inputRef}
type="number" type="number"
pattern="[0-9]*" pattern="[0-9]*"
@ -41,10 +46,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,7 +20,6 @@ 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
@ -109,7 +108,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,
@ -196,14 +195,16 @@ function NumericBuyPanel(props: {
<Spacer h={8} /> <Spacer h={8} />
{user && ( {user && (
<Button <button
disabled={betDisabled} className={clsx(
color="green" 'btn flex-1',
loading={isSubmitting} betDisabled ? 'btn-disabled' : 'btn-primary',
onClick={submitBet} isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
> >
Submit {isSubmitting ? 'Submitting...' : 'Submit'}
</Button> </button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Bet submitted!</div>}

View File

@ -54,15 +54,13 @@ 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 />}
@ -96,6 +94,7 @@ export default function Welcome() {
</Col> </Col>
</Col> </Col>
</Modal> </Modal>
</>
) )
} }
@ -118,7 +117,6 @@ 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,15 +4,18 @@ 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'
function ProbabilityInput(props: { export 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, inputClassName } = props const { prob, onChange, disabled, placeholder, className, inputClassName } =
props
const onProbChange = (str: string) => { const onProbChange = (str: string) => {
let prob = parseInt(str.replace(/\D/g, '')) let prob = parseInt(str.replace(/\D/g, ''))
@ -26,10 +29,10 @@ function ProbabilityInput(props: {
} }
return ( return (
<Col> <Col className={className}>
<label className="relative w-fit"> <label className="input-group">
<Input <Input
className={clsx('pr-2 !text-lg', inputClassName)} className={clsx('max-w-[200px] !text-lg', inputClassName)}
type="number" type="number"
max={99} max={99}
min={1} min={1}
@ -41,10 +44,9 @@ function ProbabilityInput(props: {
disabled={disabled} disabled={disabled}
onChange={(e) => onProbChange(e.target.value)} onChange={(e) => onProbChange(e.target.value)}
/> />
<span className="text-greyscale-4 absolute top-1/2 right-10 my-auto -translate-y-1/2"> <span className="bg-gray-200 text-sm">%</span>
%
</span>
</label> </label>
<Spacer h={4} />
</Col> </Col>
) )
} }
@ -80,7 +82,7 @@ export function ProbabilityOrNumericInput(props: {
/> />
) : ( ) : (
<ProbabilityInput <ProbabilityInput
inputClassName="w-24" inputClassName="w-full max-w-none"
prob={prob} prob={prob}
onChange={setProb} onChange={setProb}
disabled={isSubmitting} disabled={isSubmitting}

View File

@ -1,4 +1,5 @@
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
@ -8,7 +9,8 @@ export function ProbabilitySelector(props: {
const { probabilityInt, setProbabilityInt, isSubmitting } = props const { probabilityInt, setProbabilityInt, isSubmitting } = props
return ( return (
<label className="flex items-center text-lg"> <Row className="items-center gap-2">
<label className="input-group input-group-lg text-lg">
<Input <Input
type="number" type="number"
value={probabilityInt} value={probabilityInt}
@ -22,5 +24,6 @@ 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 { unLikeItem } from 'web/lib/firebase/likes' import { unLikeContract } 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={() => unLikeItem(user.id, likedContract.id)} onClick={() => unLikeContract(user.id, likedContract.id)}
/> />
</Row> </Row>
))} ))}

View File

@ -12,7 +12,6 @@ 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
@ -90,9 +89,11 @@ 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 ? 'hidden' : 'my-2 w-24' referredBy.length === 0
? 'hidden'
: 'btn btn-primary btn-md my-2 w-24 normal-case'
} }
disabled={referredBy.length === 0 || isSubmitting} disabled={referredBy.length === 0 || isSubmitting}
onClick={() => { onClick={() => {
@ -113,7 +114,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,16 +1,22 @@
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 = useIsClient() const [isClient, setIsClient] = useState(false)
useEffect(() => {
// Only render on client to prevent difference from server.
setIsClient(true)
}, [])
return ( return (
<DateTimeTooltip <DateTimeTooltip
className="ml-1 whitespace-nowrap text-gray-400" className="ml-1 whitespace-nowrap text-gray-400"
time={time} time={time}
> >
{isClient && fromNow(time)} {isClient ? fromNow(time) : ''}
</DateTimeTooltip> </DateTimeTooltip>
) )
} }

View File

@ -72,6 +72,7 @@ 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>

View File

@ -1,17 +0,0 @@
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

@ -0,0 +1,59 @@
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,22 +5,34 @@ 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 { IconButton } from './button' import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
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 { toastClassName, children, iconClassName, copyPayload } = props const {
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">
<IconButton <button
size="2xs" className={clsx(
className={clsx('mt-1', showToast ? 'text-indigo-600' : '')} contractDetailsButtonClassName,
buttonClassName,
showToast ? onCopyButtonClassName : ''
)}
onClick={() => { onClick={() => {
copyToClipboard(copyPayload) copyToClipboard(copyPayload)
track('copy share link') track('copy share link')
@ -29,11 +41,11 @@ export function ShareIconButton(props: {
}} }}
> >
<LinkIcon <LinkIcon
className={clsx(iconClassName ? iconClassName : 'h-5 w-5')} className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
aria-hidden="true" aria-hidden="true"
/> />
{children} {children}
</IconButton> </button>
{showToast && <ToastClipboard className={toastClassName} />} {showToast && <ToastClipboard className={toastClassName} />}
</div> </div>

View File

@ -1,20 +0,0 @@
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>
}

View File

@ -1,21 +0,0 @@
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,5 +1,6 @@
import { useState } from 'react' import { useEffect, useRef, 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'
@ -8,10 +9,8 @@ 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, TIP_UNDO_DURATION } from 'common/like' import { LIKE_TIP_AMOUNT } 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
@ -20,13 +19,24 @@ 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 [saveTip] = useState( const [localTip, setLocalTip] = useState(myTip)
() => 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
} }
@ -57,88 +67,30 @@ 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) => {
setTempTip((tempTip) => tempTip + delta) setLocalTip(localTip + delta)
const timeoutId = setTimeout(() => { me && saveTip(me, comment, localTip - myTip + delta)
me && toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
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 - tempTip >= LIKE_TIP_AMOUNT me && comment.userId !== me.id && me.balance >= localTip + 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={totalTip} totalTipped={total}
onClick={() => addTip(+LIKE_TIP_AMOUNT)} onClick={() => addTip(+LIKE_TIP_AMOUNT)}
userTipped={tempTip > 0 || myTip > 0} userTipped={localTip > 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-greyscale-4 absolute items-center' + 'border-base-300 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,7 +48,6 @@ const BOT_USERNAMES = [
'MarketManagerBot', 'MarketManagerBot',
'Botlab', 'Botlab',
'JuniorBot', 'JuniorBot',
'ManifoldDream',
] ]
function BotBadge() { function BotBadge() {

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