diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 96ec4dc2..ff3fd37d 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = { amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', // this is Phil's deployment twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + sprigEnvironmentId: 'Tu7kRZPm7daP', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index b4f00b14..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -3,6 +3,7 @@ export type EnvConfig = { firebaseConfig: FirebaseConfig amplitudeApiKey?: string twitchBotEndpoint?: string + sprigEnvironmentId?: string // IDs for v2 cloud functions -- find these by deploying a cloud function and // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app @@ -57,6 +58,7 @@ type FirebaseConfig = { export const PROD_CONFIG: EnvConfig = { domain: 'manifold.markets', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', + sprigEnvironmentId: 'sQcrq9TDqkib', firebaseConfig: { apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', diff --git a/common/post.ts b/common/post.ts index 05eab685..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,11 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 diff --git a/docs/docs/api.md b/docs/docs/api.md index 007f6fa6..5fc95a4a 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -55,6 +55,7 @@ Returns the authenticated user. Gets all groups, in no particular order. Parameters: + - `availableToUserId`: Optional. if specified, only groups that the user can join and groups they've already joined will be returned. @@ -81,7 +82,6 @@ Gets a group's markets by its unique ID. Requires no authorization. Note: group is singular in the URL. - ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -582,12 +582,17 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/add-liquidity` + +Adds a specified amount of liquidity into the market. + +- `amount`: Required. The amount of liquidity to add, in M$. ### `POST /v0/market/[marketId]/close` Closes a market on behalf of the authorized user. -- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. +- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. ### `POST /v0/market/[marketId]/resolve` diff --git a/functions/src/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 113a34bd..e9d6ae8f 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { createMarketHelper } from './create-market' +import { DAY_MS } from '../../common/util/time' const contentSchema: z.ZodType = z.lazy(() => z.intersection( @@ -35,11 +38,20 @@ const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), + + // Date doc fields: + bounty: z.number().optional(), + birthday: z.number().optional(), + type: z.string().optional(), + question: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -51,14 +63,36 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + // If this is a date doc, create a market for it. + let contractSlug + if (question) { + const closeTime = Date.now() + DAY_MS * 30 * 3 + + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) if (groupId) { diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts new file mode 100644 index 00000000..99d5f12b --- /dev/null +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -0,0 +1,52 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'http://localhost:3000' +// Dev API key for Cause Exploration Prizes (@CEP) +// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +// DEV API key for Criticism and Red Teaming (@CARTBot) +const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c' + +// Warning: Checking these in can be dangerous! +// Prod API key for @CEPBot + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`${DOMAIN}/api/v0/group/${slug}`) + return await resp.json() +} + +async function getMarketsByGroupId(id: string) { + // API structure: /v0/group/by-id/[id]/markets + const resp = await fetch(`${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +async function addLiquidityById(id: string, amount: number) { + const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + amount: amount, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug('cart-contest') + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + + // Resolve each market to NO + for (const market of markets.slice(0, 3)) { + console.log(market.slug, market.totalLiquidity) + const resp = await addLiquidityById(market.id, 200) + console.log(resp) + } +} +main() diff --git a/functions/src/scripts/contest/create-markets.ts b/functions/src/scripts/contest/bulk-create-markets.ts similarity index 100% rename from functions/src/scripts/contest/create-markets.ts rename to functions/src/scripts/contest/bulk-create-markets.ts diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 1b9bddbe..90918283 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -401,7 +401,7 @@ export function BuyPanel(props: { isSubmitting={isSubmitting} openModalButtonClass={clsx( 'btn mb-2 flex-1', - betDisabled + betDisabled || outcome === undefined ? 'btn-disabled bg-greyscale-2' : outcome === 'NO' ? 'border-none bg-red-400 hover:bg-red-500' diff --git a/web/components/bet-summary.tsx b/web/components/bet-summary.tsx new file mode 100644 index 00000000..aa64da43 --- /dev/null +++ b/web/components/bet-summary.tsx @@ -0,0 +1,120 @@ +import { sumBy } from 'lodash' +import clsx from 'clsx' + +import { Bet } from 'web/lib/firebase/bets' +import { formatMoney, formatWithCommas } from 'common/util/format' +import { Col } from './layout/col' +import { Contract } from 'web/lib/firebase/contracts' +import { Row } from './layout/row' +import { YesLabel, NoLabel } from './outcome-label' +import { + calculatePayout, + getContractBetMetrics, + getProbability, +} from 'common/calculate' +import { InfoTooltip } from './info-tooltip' +import { ProfitBadge } from './profit-badge' + +export function BetsSummary(props: { + contract: Contract + userBets: Bet[] + className?: string +}) { + const { contract, className } = props + const { resolution, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + const bets = props.userBets.filter((b) => !b.isAnte) + const { profitPercent, payout, profit, invested } = getContractBetMetrics( + contract, + bets + ) + + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const yesWinnings = sumBy(excludeSales, (bet) => + calculatePayout(contract, bet, 'YES') + ) + const noWinnings = sumBy(excludeSales, (bet) => + calculatePayout(contract, bet, 'NO') + ) + + const position = yesWinnings - noWinnings + + const prob = isBinary ? getProbability(contract) : 0 + const expectation = prob * yesWinnings + (1 - prob) * noWinnings + + if (bets.length === 0) return <> + + return ( + + + {resolution ? ( + +
Payout
+
+ {formatMoney(payout)}{' '} + +
+ + ) : isBinary ? ( + +
+ Position{' '} + +
+
+ {position > 1e-7 ? ( + <> + {formatWithCommas(position)} + + ) : position < -1e-7 ? ( + <> + {formatWithCommas(-position)} + + ) : ( + '——' + )} +
+ + ) : ( + +
+ Expectation{''} + +
+
{formatMoney(payout)}
+ + )} + + +
+ Invested{' '} + +
+
{formatMoney(invested)}
+ + + {isBinary && !resolution && ( + +
+ Expectation{' '} + +
+
{formatMoney(expectation)}
+ + )} + + +
+ Profit{' '} + +
+
+ {formatMoney(profit)} + +
+ +
+ + ) +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index dbb2db56..5a95f22f 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -22,7 +22,7 @@ import { import { Row } from './layout/row' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' -import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' +import { OutcomeLabel } from './outcome-label' import { LoadingIndicator } from './loading-indicator' import { SiteLink } from './site-link' import { @@ -38,14 +38,14 @@ import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { useUserBets } from 'web/hooks/use-user-bets' -import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' -import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' +import { BetsSummary } from './bet-summary' +import { ProfitBadge } from './profit-badge' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -337,8 +337,7 @@ function ContractBets(props: { {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( @@ -364,125 +363,6 @@ function ContractBets(props: { ) } -export function BetsSummary(props: { - contract: Contract - bets: Bet[] - isYourBets: boolean - className?: string -}) { - const { contract, isYourBets, className } = props - const { resolution, closeTime, outcomeType, mechanism } = contract - const isBinary = outcomeType === 'BINARY' - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - const isCpmm = mechanism === 'cpmm-1' - const isClosed = closeTime && Date.now() > closeTime - - const bets = props.bets.filter((b) => !b.isAnte) - const { hasShares, invested, profitPercent, payout, profit, totalShares } = - getContractBetMetrics(contract, bets) - - const excludeSales = bets.filter((b) => !b.isSold && !b.sale) - const yesWinnings = sumBy(excludeSales, (bet) => - calculatePayout(contract, bet, 'YES') - ) - const noWinnings = sumBy(excludeSales, (bet) => - calculatePayout(contract, bet, 'NO') - ) - - const [showSellModal, setShowSellModal] = useState(false) - const user = useUser() - - const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0) - ? floatingEqual(totalShares.NO ?? 0, 0) - ? undefined - : 'NO' - : 'YES' - - const canSell = - isYourBets && - isCpmm && - (isBinary || isPseudoNumeric) && - !isClosed && - !resolution && - hasShares && - sharesOutcome && - user - - return ( - - - -
- Invested -
-
{formatMoney(invested)}
- - -
Profit
-
- {formatMoney(profit)} -
- - {canSell && ( - <> - - {showSellModal && ( - - )} - - )} -
- - {resolution ? ( - -
Payout
-
- {formatMoney(payout)}{' '} - -
- - ) : isBinary ? ( - <> - -
- Payout if -
-
- {formatMoney(yesWinnings)} -
- - -
- Payout if -
-
{formatMoney(noWinnings)}
- - - ) : ( - -
- Expected value -
-
{formatMoney(payout)}
- - )} -
- - ) -} - export function ContractBetsTable(props: { contract: Contract bets: Bet[] @@ -750,30 +630,3 @@ function SellButton(props: { ) } - -export function ProfitBadge(props: { - profitPercent: number - round?: boolean - className?: string -}) { - const { profitPercent, round, className } = props - if (!profitPercent) return null - const colors = - profitPercent > 0 - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' - - return ( - - {(profitPercent > 0 ? '+' : '') + - profitPercent.toFixed(round ? 0 : 1) + - '%'} - - ) -} diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 39585b91..eda6b4f5 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -9,7 +9,7 @@ import { groupBy, sortBy, sum } from 'lodash' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { PAST_BETS } from 'common/user' -import { ContractBetsTable, BetsSummary } from '../bets-list' +import { ContractBetsTable } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' @@ -17,12 +17,13 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { useComments } from 'web/hooks/use-comments' import { useLiquidity } from 'web/hooks/use-liquidity' import { useTipTxns } from 'web/hooks/use-tip-txns' -import { useUser } from 'web/hooks/use-user' import { capitalize } from 'lodash' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' +import { buildArray } from 'common/util/array' + import { useIsMobile } from 'web/hooks/use-is-mobile' import { formatMoney } from 'common/util/format' import { Button } from 'web/components/button' @@ -37,26 +38,23 @@ export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { const userBets = user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) +export function ContractTabs(props: { + contract: Contract + bets: Bet[] + userBets: Bet[] +}) { + const { contract, bets, userBets } = props + const yourTrades = (
- - +
) - return ( - , }, - { - title: capitalize(PAST_BETS), - content: , - }, - ...(!user || !userBets?.length - ? [] - : [ - { - title: isMobile ? `You` : `Your ${PAST_BETS}`, - content: yourTrades, - }, - ]), - ]} - /> + { + title: capitalize(PAST_BETS), + content: , + }, + userBets.length > 0 && { + title: 'Your trades', + content: yourTrades, + } + ) + + return ( + ) } diff --git a/web/components/nav/bottom-nav-bar.tsx b/web/components/nav/bottom-nav-bar.tsx index f906b21d..922e6646 100644 --- a/web/components/nav/bottom-nav-bar.tsx +++ b/web/components/nav/bottom-nav-bar.tsx @@ -20,8 +20,6 @@ import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' import { User } from 'common/user' -import { PAST_BETS } from 'common/user' - function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, @@ -42,7 +40,7 @@ const signedOutNavigation = [ export const userProfileItem = (user: User) => ({ name: formatMoney(user.balance), trackingEventName: 'profile', - href: `/${user.username}?tab=${PAST_BETS}`, + href: `/${user.username}?tab=portfolio`, icon: () => ( + 0 + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' + + return ( + + {(profitPercent > 0 ? '+' : '') + + profitPercent.toFixed(round ? 0 : 1) + + '%'} + + ) +} diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index f395e6a9..2b97f07d 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -6,13 +6,15 @@ export const linkClass = 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' export const SiteLink = (props: { - href: string + href: string | undefined children?: ReactNode onClick?: () => void className?: string }) => { const { href, children, onClick, className } = props + if (!href) return <>{children} + return ( m.slug)) + const probs = contracts.map((c) => + c ? getProbability(c as CPMMBinaryContract) : 0.5 + ) + const marketsWithProbs = zip(markets, probs) as [ + StateElectionMarket, + number + ][] + + const stateInfo = marketsWithProbs.map(([market, prob]) => [ + market.state, + { + fill: probToColor(prob, market.isWinRepublican), + clickHandler: () => + Router.push(`/${market.creatorUsername}/${market.slug}`), + }, + ]) + + const config = Object.fromEntries(stateInfo) as Customize + + return +} + +const probToColor = (prob: number, isWinRepublican: boolean) => { + const p = isWinRepublican ? prob : 1 - prob + const hue = p > 0.5 ? 350 : 240 + const saturation = 100 + const lightness = 100 - 50 * Math.abs(p - 0.5) + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} + +const useContracts = (slugs: string[]) => { + const [contracts, setContracts] = useState<(Contract | undefined)[]>( + slugs.map(() => undefined) + ) + + useEffect(() => { + Promise.all(slugs.map((slug) => getContractFromSlug(slug))).then( + (contracts) => setContracts(contracts) + ) + }, [slugs]) + + useEffect(() => { + if (contracts.some((c) => c === undefined)) return + + // listen to contract updates + const unsubs = (contracts as Contract[]).map((c, i) => + listenForContract( + c.id, + (newC) => newC && setContracts(setAt(contracts, i, newC)) + ) + ) + return () => unsubs.forEach((u) => u()) + }, [contracts]) + + return contracts +} + +function setAt(arr: T[], i: number, val: T) { + const newArr = [...arr] + newArr[i] = val + return newArr +} diff --git a/web/components/usa-map/usa-map.tsx b/web/components/usa-map/usa-map.tsx new file mode 100644 index 00000000..2841e04c --- /dev/null +++ b/web/components/usa-map/usa-map.tsx @@ -0,0 +1,106 @@ +// https://github.com/jb-1980/usa-map-react +// MIT License + +import { DATA } from './data' +import { USAState } from './usa-state' + +export type ClickHandler = ( + e: React.MouseEvent +) => R +export type GetClickHandler = (stateKey: string) => ClickHandler | undefined +export type CustomizeObj = { + fill?: string + clickHandler?: ClickHandler +} +export interface Customize { + [key: string]: CustomizeObj +} + +export type StatesProps = { + hideStateTitle?: boolean + fillStateColor: (stateKey: string) => string + stateClickHandler: GetClickHandler +} +const States = ({ + hideStateTitle, + fillStateColor, + stateClickHandler, +}: StatesProps) => + Object.entries(DATA).map(([stateKey, data]) => ( + + )) + +type USAMapPropTypes = { + onClick?: ClickHandler + width?: number + height?: number + title?: string + defaultFill?: string + customize?: Customize + hideStateTitle?: boolean + className?: string +} + +export const USAMap = ({ + onClick = (e) => { + console.log(e.currentTarget.dataset.name) + }, + width = 959, + height = 593, + title = 'US states map', + defaultFill = '#d3d3d3', + customize, + hideStateTitle, + className, +}: USAMapPropTypes) => { + const fillStateColor = (state: string) => + customize?.[state]?.fill ? (customize[state].fill as string) : defaultFill + + const stateClickHandler = (state: string) => customize?.[state]?.clickHandler + + return ( + + {title} + + {States({ + hideStateTitle, + fillStateColor, + stateClickHandler, + })} + + + + + + + ) +} diff --git a/web/components/usa-map/usa-state.tsx b/web/components/usa-map/usa-state.tsx new file mode 100644 index 00000000..9bebd027 --- /dev/null +++ b/web/components/usa-map/usa-state.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx' +import { ClickHandler } from './usa-map' + +type USAStateProps = { + state: string + dimensions: string + fill: string + onClickState?: ClickHandler + stateName: string + hideStateTitle?: boolean +} +export const USAState = ({ + state, + dimensions, + fill, + onClickState, + stateName, + hideStateTitle, +}: USAStateProps) => { + return ( + + {hideStateTitle ? null : {stateName}} + + ) +} diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 36007048..22b9d095 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -6,8 +6,9 @@ import { updateDoc, where, } from 'firebase/firestore' -import { Post } from 'common/post' -import { coll, getValue, listenForValue } from './utils' +import { DateDoc, Post } from 'common/post' +import { coll, getValue, getValues, listenForValue } from './utils' +import { getUserByUsername } from './users' export const posts = coll('posts') @@ -44,3 +45,22 @@ export async function listPosts(postIds?: string[]) { if (postIds === undefined) return [] return Promise.all(postIds.map(getPost)) } + +export async function getDateDocs() { + const q = query(posts, where('type', '==', 'date-doc')) + return getValues(q) +} + +export async function getDateDoc(username: string) { + const user = await getUserByUsername(username) + if (!user) return null + + const q = query( + posts, + where('type', '==', 'date-doc'), + where('creatorId', '==', user.id) + ) + const docs = await getValues(q) + const post = docs.length === 0 ? null : docs[0] + return { post, user } +} diff --git a/web/lib/service/analytics.ts b/web/lib/service/analytics.ts index 3ac58055..780d9ba4 100644 --- a/web/lib/service/analytics.ts +++ b/web/lib/service/analytics.ts @@ -6,6 +6,8 @@ import { Identify, } from '@amplitude/analytics-browser' +import * as Sprig from 'web/lib/service/sprig' + import { ENV_CONFIG } from 'common/envs/constants' init(ENV_CONFIG.amplitudeApiKey ?? '', undefined, { includeReferrer: true }) @@ -33,10 +35,12 @@ export const withTracking = export async function identifyUser(userId: string) { setUserId(userId) + Sprig.setUserId(userId) } export async function setUserProperty(property: string, value: string) { const identifyObj = new Identify() identifyObj.set(property, value) await identify(identifyObj) + Sprig.setAttributes({ [property]: value }) } diff --git a/web/lib/service/sprig.ts b/web/lib/service/sprig.ts new file mode 100644 index 00000000..9353020d --- /dev/null +++ b/web/lib/service/sprig.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// Integrate Sprig + +import { ENV_CONFIG } from 'common/envs/constants' + +try { + ;(function (l, e, a, p) { + if (window.Sprig) return + window.Sprig = function (...args) { + S._queue.push(args) + } + const S = window.Sprig + S.appId = a + S._queue = [] + window.UserLeap = S + a = l.createElement('script') + a.async = 1 + a.src = e + '?id=' + S.appId + p = l.getElementsByTagName('script')[0] + p.parentNode.insertBefore(a, p) + })(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId) +} catch (error) { + console.log('Error initializing Sprig, please complain to Barak', error) +} + +export function setUserId(userId: string): void { + window.Sprig('setUserId', userId) +} + +export function setAttributes(attributes: Record): void { + window.Sprig('setAttributes', attributes) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 38df2fbf..1dde2f95 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,5 +1,6 @@ import React, { memo, useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import dayjs from 'dayjs' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' @@ -44,8 +45,7 @@ import { useAdmin } from 'web/hooks/use-admin' import { BetSignUpPrompt } from 'web/components/sign-up-prompt' import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' import BetButton from 'web/components/bet-button' - -import dayjs from 'dayjs' +import { BetsSummary } from 'web/components/bet-summary' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -167,6 +167,10 @@ export function ContractPageContent( [bets] ) + const userBets = user + ? bets.filter((bet) => !bet.isAnte && bet.userId === user.id) + : [] + const [showConfetti, setShowConfetti] = useState(false) useEffect(() => { @@ -248,7 +252,14 @@ export function ContractPageContent( )} - + + + + {!user ? ( diff --git a/web/pages/api/v0/market/[id]/add-liquidity.ts b/web/pages/api/v0/market/[id]/add-liquidity.ts new file mode 100644 index 00000000..3e1a3ed9 --- /dev/null +++ b/web/pages/api/v0/market/[id]/add-liquidity.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + + const { id } = req.query + const contractId = id as string + + if (req.body) req.body.contractId = contractId + try { + const backendRes = await fetchBackend(req, 'addliquidity') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx new file mode 100644 index 00000000..17a41445 --- /dev/null +++ b/web/pages/date-docs/[username].tsx @@ -0,0 +1,154 @@ +import { getDateDoc } from 'web/lib/firebase/posts' +import { ArrowLeftIcon, LinkIcon } from '@heroicons/react/outline' +import { Page } from 'web/components/page' +import dayjs from 'dayjs' + +import { DateDoc } from 'common/post' +import { Content } from 'web/components/editor' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { SiteLink } from 'web/components/site-link' +import { User } from 'web/lib/firebase/users' +import { DOMAIN } from 'common/envs/constants' +import Custom404 from '../404' +import { ShareIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { Button } from 'web/components/button' +import { track } from '@amplitude/analytics-browser' +import toast from 'react-hot-toast' +import { copyToClipboard } from 'web/lib/util/copy' +import { useUser } from 'web/hooks/use-user' +import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]' +import { usePost } from 'web/hooks/use-post' +import { useTipTxns } from 'web/hooks/use-tip-txns' +import { useCommentsOnPost } from 'web/hooks/use-comments' + +export async function getStaticProps(props: { params: { username: string } }) { + const { username } = props.params + const { user: creator, post } = (await getDateDoc(username)) ?? { + creator: null, + post: null, + } + + return { + props: { + creator, + post, + }, + revalidate: 5, // regenerate after five seconds + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function DateDocPageHelper(props: { + creator: User | null + post: DateDoc | null +}) { + const { creator, post } = props + + if (!creator || !post) return + + return +} + +function DateDocPage(props: { creator: User; post: DateDoc }) { + const { creator, post } = props + + const tips = useTipTxns({ postId: post.id }) + const comments = useCommentsOnPost(post.id) ?? [] + + return ( + + + + + + + + +
Add your endorsement of {creator.name}!
+ + + +
+ ) +} + +export function DateDocPost(props: { + dateDoc: DateDoc + creator: User + link?: boolean +}) { + const { dateDoc, creator, link } = props + const { content, birthday, contractSlug } = dateDoc + const { name, username } = creator + + const user = useUser() + const post = usePost(dateDoc.id) ?? dateDoc + + const age = dayjs().diff(birthday, 'year') + const shareUrl = `https://${DOMAIN}/date-docs/${username}` + const marketUrl = `https://${DOMAIN}/${username}/${contractSlug}` + + return ( + + + + +
+ {name}, {age} +
+ + + + +
+ +
+ {user && user.id === creator.id ? ( + + ) : ( + + )} +
+ +
+ + ) +} diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx new file mode 100644 index 00000000..6065796d --- /dev/null +++ b/web/pages/date-docs/create.tsx @@ -0,0 +1,124 @@ +import Router from 'next/router' +import { useEffect, useState } from 'react' +import Textarea from 'react-expanding-textarea' + +import { DateDoc } from 'common/post' +import { useTextEditor, TextEditor } from 'web/components/editor' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { createPost } from 'web/lib/firebase/api' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import dayjs from 'dayjs' +import { MINUTE_MS } from 'common/util/time' +import { Col } from 'web/components/layout/col' +import { MAX_QUESTION_LENGTH } from 'common/contract' + +export default function CreateDateDocPage() { + const user = useUser() + + useEffect(() => { + if (user === null) Router.push('/date') + }) + + const title = `${user?.name}'s Date Doc` + const [birthday, setBirthday] = useState(undefined) + const [question, setQuestion] = useState( + 'Will I find a partner in the next 3 months?' + ) + + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + disabled: isSubmitting, + }) + + const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined + const isValid = + user && birthday && editor && editor.isEmpty === false && question + + async function saveDateDoc() { + if (!user || !editor || !birthdayTime) return + + const newPost: Omit< + DateDoc, + 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' + > & { question: string } = { + title, + content: editor.getJSON(), + bounty: 0, + birthday: birthdayTime, + type: 'date-doc', + question, + } + + const result = await createPost(newPost) + + if (result.post) { + await Router.push(`/date-docs/${user.username}`) + } + } + + return ( + +
+
+ + + <Button + type="submit" + disabled={isSubmitting || !isValid || upload.isLoading} + onClick={async () => { + setIsSubmitting(true) + await saveDateDoc() + setIsSubmitting(false) + }} + color="blue" + > + {isSubmitting ? 'Publishing...' : 'Publish'} + </Button> + </Row> + + <Col className="gap-8"> + <Col className="max-w-[160px] justify-start gap-4"> + <div className="">Birthday</div> + <input + type={'date'} + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setBirthday(e.target.value)} + max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} + disabled={isSubmitting} + value={birthday} + /> + </Col> + + <Col className="gap-4"> + <div className=""> + Tell us about you! What are you looking for? + </div> + <TextEditor editor={editor} upload={upload} /> + </Col> + + <Col className="gap-4"> + <div className=""> + Finally, we'll create an (unlisted) prediction market! + </div> + + <Col className="gap-2"> + <Textarea + className="input input-bordered resize-none" + maxLength={MAX_QUESTION_LENGTH} + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + <div className="ml-2 text-gray-500">Cost: M$100</div> + </Col> + </Col> + </Col> + </div> + </div> + </Page> + ) +} diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx new file mode 100644 index 00000000..9ddeb57f --- /dev/null +++ b/web/pages/date-docs/index.tsx @@ -0,0 +1,72 @@ +import { Page } from 'web/components/page' +import { PlusCircleIcon } from '@heroicons/react/outline' + +import { getDateDocs } from 'web/lib/firebase/posts' +import type { DateDoc } from 'common/post' +import { Title } from 'web/components/title' +import { Spacer } from 'web/components/layout/spacer' +import { Col } from 'web/components/layout/col' +import { useUser } from 'web/hooks/use-user' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { SiteLink } from 'web/components/site-link' +import { getUser, User } from 'web/lib/firebase/users' +import { DateDocPost } from './[username]' + +export async function getStaticProps() { + const dateDocs = await getDateDocs() + const docCreators = await Promise.all( + dateDocs.map((d) => getUser(d.creatorId)) + ) + + return { + props: { + dateDocs, + docCreators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function DatePage(props: { + dateDocs: DateDoc[] + docCreators: User[] +}) { + const { dateDocs, docCreators } = props + const user = useUser() + + const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) + + return ( + <Page> + <div className="mx-auto w-full max-w-xl"> + <Row className="items-center justify-between p-4 sm:p-0"> + <Title className="!my-0 px-2 text-blue-500" text="Date docs" /> + {!hasDoc && ( + <SiteLink href="/date-docs/create" className="!no-underline"> + <Button className="flex flex-row gap-1" color="blue"> + <PlusCircleIcon + className={'h-5 w-5 flex-shrink-0 text-white'} + aria-hidden="true" + /> + New + </Button> + </SiteLink> + )} + </Row> + <Spacer h={6} /> + <Col className="gap-4"> + {dateDocs.map((dateDoc, i) => ( + <DateDocPost + key={dateDoc.id} + dateDoc={dateDoc} + creator={docCreators[i]} + link + /> + ))} + </Col> + </div> + </Page> + ) +} diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index b42b37bb..ba2851bf 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -33,7 +33,6 @@ import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { formatMoney } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' -import { ProfitBadge } from 'web/components/bets-list' import { calculatePortfolioProfit } from 'common/calculate-metrics' import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' import { ContractsGrid } from 'web/components/contract/contracts-grid' @@ -45,6 +44,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch' import { Title } from 'web/components/title' import { CPMMBinaryContract } from 'common/contract' import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts' +import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' export default function Home() { diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx new file mode 100644 index 00000000..1ae72f7a --- /dev/null +++ b/web/pages/midterms.tsx @@ -0,0 +1,92 @@ +import { Col } from 'web/components/layout/col' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { + StateElectionMarket, + StateElectionMap, +} from 'web/components/usa-map/state-election-map' + +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, + }, +] + +const App = () => { + return ( + <Page className=""> + <Col className="items-center justify-center"> + <Title text="2022 US Senate Midterms" className="mt-8" /> + <StateElectionMap markets={senateMidterms} /> + + <iframe + src="https://manifold.markets/embed/NathanpmYoung/will-the-democrats-control-the-sena" + title="Will the Democrats control the Senate after the Midterms?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> + </Col> + </Page> + ) +} + +export default App diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 6cd4408f..b71b7cca 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -34,9 +34,9 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) { return { props: { - post: post, - creator: creator, - comments: comments, + post, + creator, + comments, }, revalidate: 60, // regenerate after a minute @@ -117,12 +117,7 @@ export default function PostPage(props: { <Spacer h={4} /> <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> - <PostCommentsActivity - post={post} - comments={comments} - tips={tips} - user={creator} - /> + <PostCommentsActivity post={post} comments={comments} tips={tips} /> </div> </div> </Page> @@ -133,9 +128,8 @@ export function PostCommentsActivity(props: { post: Post comments: PostComment[] tips: CommentTipMap - user: User | null | undefined }) { - const { post, comments, user, tips } = props + const { post, comments, tips } = props const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const topLevelComments = sortBy( @@ -149,7 +143,6 @@ export function PostCommentsActivity(props: { {topLevelComments.map((parent) => ( <PostCommentThread key={parent.id} - user={user} post={post} parentComment={parent} threadComments={sortBy( @@ -164,7 +157,7 @@ export function PostCommentsActivity(props: { ) } -function RichEditPost(props: { post: Post }) { +export function RichEditPost(props: { post: Post }) { const { post } = props const [editing, setEditing] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index b98887bb..f1d50a29 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core' import clsx from 'clsx' import { PostComment } from 'common/comment' import { Post } from 'common/post' -import { User } from 'common/user' import { Dictionary } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' @@ -21,7 +20,6 @@ import { createCommentOnPost } from 'web/lib/firebase/comments' import { firebaseLogin } from 'web/lib/firebase/users' export function PostCommentThread(props: { - user: User | null | undefined post: Post threadComments: PostComment[] tips: CommentTipMap