Challenge Bets (#679)

* Challenge bets

* Store avatar url

* Fix before and after probs

* Check balance before creation

* Calculate winning shares

* pretty

* Change winning value

* Set shares to equal each other

* Fix share challenge link

* pretty

* remove lib refs

* Probability of bet is set to market

* Remove peer pill

* Cleanup

* Button on contract page

* don't show challenge if not binary or if resolved

* challenge button (WIP)

* fix accept challenge: don't change pool/probability

* Opengraph preview [WIP]

* elim lib

* Edit og card props

* Change challenge text

* New card gen attempt

* Get challenge on server

* challenge button styling

* Use env domain

* Remove other window ref

* Use challenge creator as avatar

* Remove user name

* Remove s from property, replace prob with outcome

* challenge form

* share text

* Add in challenge parts to template and url

* Challenge url params optional

* Add challenge params to parse request

* Parse please

* Don't remove prob

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Challenge card styling

* Add to readme about how to dev og-image

* Add emojis

* button: gradient background, 2xl size

* beautify accept bet screen

* update question button

* Add separate challenge template

* Accepted challenge sharing card, fix accept bet call

* accept challenge button

* challenge winner page

* create challenge screen

* Your outcome/cost=> acceptorOutcome/cost

* New create challenge panel

* Fix main merge

* Add challenge slug to bet and filter by it

* Center title

* Add helper text

* Add FAQ section

* Lint

* Columnize the user areas in preview link too

* Absolutely position

* Spacing

* Orientation

* Restyle challenges list, cache contract name

* Make copying easy on mobile

* Link spacing

* Fix spacing

* qr codes!

* put your challenges first

* eslint

* Changes to contract buttons and create challenge modal

* Change titles around for current bet

* Add back in contract title after winning

* Cleanup

* Add challenge enabled flag

* Spacing of switch button

* Put sharing qr code  in modal

Co-authored-by: mantikoros <sgrugett@gmail.com>
This commit is contained in:
Ian Philips 2022-08-04 15:27:02 -06:00 committed by GitHub
parent 2d3ca47b52
commit 798253f887
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2233 additions and 197 deletions

View File

@ -26,6 +26,7 @@ export type Bet = {
isAnte?: boolean
isLiquidityProvision?: boolean
isRedemption?: boolean
challengeSlug?: string
} & Partial<LimitProps>
export type NumericBet = Bet & {

63
common/challenge.ts Normal file
View File

@ -0,0 +1,63 @@
export type Challenge = {
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
// Also functions as the unique id for the link.
slug: string
// The user that created the challenge.
creatorId: string
creatorUsername: string
creatorName: string
creatorAvatarUrl?: string
// Displayed to people claiming the challenge
message: string
// How much to put up
creatorAmount: number
// YES or NO for now
creatorOutcome: string
// Different than the creator
acceptorOutcome: string
acceptorAmount: number
// The probability the challenger thinks
creatorOutcomeProb: number
contractId: string
contractSlug: string
contractQuestion: string
contractCreatorUsername: string
createdTime: number
// If null, the link is valid forever
expiresTime: number | null
// How many times the challenge can be used
maxUses: number
// Used for simpler caching
acceptedByUserIds: string[]
// Successful redemptions of the link
acceptances: Acceptance[]
// TODO: will have to fill this on resolve contract
isResolved: boolean
resolutionOutcome?: string
}
export type Acceptance = {
// User that accepted the challenge
userId: string
userUsername: string
userName: string
userAvatarUrl: string
// The ID of the successful bet that tracks the money moved
betId: string
createdTime: number
}
export const CHALLENGES_ENABLED = true

View File

@ -37,6 +37,7 @@ export type notification_source_types =
| 'group'
| 'user'
| 'bonus'
| 'challenge'
export type notification_source_update_types =
| 'created'
@ -64,3 +65,4 @@ export type notification_reason_types =
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'

View File

@ -39,6 +39,17 @@ service cloud.firestore {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{
allow read;
}
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
// allow update if there have been no claims yet and if the challenge is still open
allow update: if request.auth.uid == resource.data.creatorId;
}
match /users/{userId}/follows/{followUserId} {
allow read;
allow write: if request.auth.uid == userId;

View File

@ -0,0 +1,164 @@
import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api'
import { log } from './utils'
import { Contract, CPMMBinaryContract } from '../../common/contract'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
import { removeUndefinedProps } from '../../common/util/object'
import { Acceptance, Challenge } from '../../common/challenge'
import { CandidateBet } from '../../common/new-bet'
import { createChallengeAcceptedNotification } from './create-notification'
import { noFees } from '../../common/fees'
import { formatMoney, formatPercent } from '../../common/util/format'
const bodySchema = z.object({
contractId: z.string(),
challengeSlug: z.string(),
outcomeType: z.literal('BINARY'),
closeTime: z.number().gte(Date.now()),
})
const firestore = admin.firestore()
export const acceptchallenge = newEndpoint({}, async (req, auth) => {
const { challengeSlug, contractId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const challengeDoc = firestore.doc(
`contracts/${contractId}/challenges/${challengeSlug}`
)
const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
contractDoc,
userDoc,
challengeDoc
)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
const anyContract = contractSnap.data() as Contract
const user = userSnap.data() as User
const challenge = challengeSnap.data() as Challenge
if (challenge.acceptances.length > 0)
throw new APIError(400, 'Challenge already accepted.')
const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
const creatorSnap = await trans.get(creatorDoc)
if (!creatorSnap.exists) throw new APIError(400, 'User not found.')
const creator = creatorSnap.data() as User
const {
creatorAmount,
acceptorOutcome,
creatorOutcome,
creatorOutcomeProb,
acceptorAmount,
} = challenge
if (user.balance < acceptorAmount)
throw new APIError(400, 'Insufficient balance.')
const contract = anyContract as CPMMBinaryContract
const shares = (1 / creatorOutcomeProb) * creatorAmount
const createdTime = Date.now()
const probOfYes =
creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb
log(
'Creating challenge bet for',
user.username,
shares,
acceptorOutcome,
'shares',
'at',
formatPercent(creatorOutcomeProb),
'for',
formatMoney(acceptorAmount)
)
const yourNewBet: CandidateBet = removeUndefinedProps({
orderAmount: acceptorAmount,
amount: acceptorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: acceptorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const yourNewBetDoc = contractDoc.collection('bets').doc()
trans.create(yourNewBetDoc, {
id: yourNewBetDoc.id,
userId: user.id,
...yourNewBet,
})
trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
const creatorNewBet: CandidateBet = removeUndefinedProps({
orderAmount: creatorAmount,
amount: creatorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: creatorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const creatorBetDoc = contractDoc.collection('bets').doc()
trans.create(creatorBetDoc, {
id: creatorBetDoc.id,
userId: creator.id,
...creatorNewBet,
})
trans.update(creatorDoc, {
balance: FieldValue.increment(-creatorNewBet.amount),
})
const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount
trans.update(contractDoc, { volume })
trans.update(
challengeDoc,
removeUndefinedProps({
acceptedByUserIds: [user.id],
acceptances: [
{
userId: user.id,
betId: yourNewBetDoc.id,
createdTime,
amount: acceptorAmount,
userUsername: user.username,
userName: user.name,
userAvatarUrl: user.avatarUrl,
} as Acceptance,
],
})
)
await createChallengeAcceptedNotification(
user,
creator,
challenge,
acceptorAmount,
contract
)
log('Done, sent notification.')
return yourNewBetDoc
})
return { betId: result.id }
})

View File

@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
const firestore = admin.firestore()
type user_to_reason_texts = {
@ -478,3 +479,35 @@ export const createReferralNotification = async (
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (
challenger: User,
challengeCreator: User,
challenge: Challenge,
acceptedAmount: number,
contract: Contract
) => {
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: challengeCreator.id,
reason: 'challenge_accepted',
createdTime: Date.now(),
isSeen: false,
sourceId: challenge.slug,
sourceType: 'challenge',
sourceUpdateType: 'updated',
sourceUserName: challenger.name,
sourceUserUsername: challenger.username,
sourceUserAvatarUrl: challenger.avatarUrl,
sourceText: acceptedAmount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -64,6 +64,7 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -87,6 +88,7 @@ const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
export {
healthFunction as health,
@ -108,4 +110,5 @@ export {
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
}

View File

@ -1,32 +1,35 @@
# Installing
1. `yarn install`
2. `yarn start`
3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]`
4. `Manifold Markets` to `Which scope should contain your project? [Y/n] `
5. `Y` to `Link to existing project? [Y/n] `
6. `opengraph-image` to `Whats the name of your existing project?`
# Quickstart
1. To get started: `yarn install`
2. To test locally: `yarn start`
1. To test locally: `yarn start`
The local image preview is broken for some reason; but the service works.
E.g. try `http://localhost:3000/manifold.png`
3. To deploy: push to Github
2. To deploy: push to Github
- note: (Not `dev` because that's reserved for Vercel)
- note2: (Or `cd .. && vercel --prod`, I think)
For more info, see Contributing.md
- note2: You may have to configure Vercel the first time:
```
$ yarn start
yarn run v1.22.10
$ cd .. && vercel dev
Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback
? Set up and develop “~/Code/mantic”? [Y/n] y
? Which scope should contain your project? Mantic Markets
? Found project “mantic/mantic”. Link to it? [Y/n] n
? Link to different existing project? [Y/n] y
? Whats the name of your existing project? manifold-og-image
```
- note2: (Not `dev` because that's reserved for Vercel)
- note3: (Or `cd .. && vercel --prod`, I think)
(Everything below is from the original repo)
# Development
- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI.
- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters.
- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to
`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch.
You have to find your opengraph-image branch's url and replace the part before `m.png` with it.
- You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.`
- Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached.
- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github:
![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png)
# [Open Graph Image as a Service](https://og-image.vercel.app)
<a href="https://twitter.com/vercel">

View File

@ -0,0 +1,203 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getChallengeHtml(parsedReq: ParsedRequest) {
const {
theme,
fontSize,
question,
creatorName,
creatorAvatarUrl,
challengerAmount,
challengerOutcome,
creatorAmount,
creatorOutcome,
acceptedName,
acceptedAvatarUrl,
} = parsedReq
const MAX_QUESTION_CHARS = 78
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden'
const accepted = acceptedName !== ''
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
${getCss(theme, fontSize)}
</style>
<body>
<div class="px-24">
<div class="flex flex-col justify-between gap-16 pt-2">
<div class="flex flex-col text-indigo-700 mt-4 text-5xl leading-tight text-center">
${truncatedQuestion}
</div>
<div class="flex flex-row grid grid-cols-3">
<div class="flex flex-col justify-center items-center ${
creatorOutcome === 'YES' ? 'text-primary' : 'text-red-500'
}">
<!-- Creator user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
<p class="text-gray-900 text-4xl">${creatorName}</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAvatar}"
src="${creatorAvatarUrl}"
alt=""
/>
</div>
<div class="flex flex-row justify-center items-center gap-3 mt-6">
<div class="text-5xl">${'M$' + creatorAmount}</div>
<div class="text-4xl">${'on'}</div>
<div class="text-5xl ">${creatorOutcome}</div>
</div>
</div>
<!-- VS-->
<div class="flex flex-col text-gray-900 text-6xl mt-8 text-center">
VS
</div>
<div class="flex flex-col justify-center items-center ${
challengerOutcome === 'YES' ? 'text-primary' : 'text-red-500'
}">
<!-- Unaccepted user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center
${accepted ? 'hidden' : ''}">
<p class="text-gray-900 text-4xl">You</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center "
src="https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"
alt=""
/>
</div>
<!-- Accepted user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
<p class="text-gray-900 text-4xl">${acceptedName}</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAcceptedAvatar}"
src="${acceptedAvatarUrl}"
alt=""
/>
</div>
<div class="flex flex-row justify-center items-center gap-3 mt-6">
<div class="text-5xl">${'M$' + challengerAmount}</div>
<div class="text-4xl">${'on'}</div>
<div class="text-5xl ">${challengerOutcome}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Manifold logo -->
<div class="flex flex-row justify-center absolute bottom-4 left-[24rem]">
<a class="flex flex-row gap-3" href="/">
<img
class="sm:h-12 sm:w-12"
src="https:&#x2F;&#x2F;manifold.markets&#x2F;logo.png"
width="40"
height="40"
alt=''
/>
<div
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
>
Manifold Markets
</div></a>
</div>
</div>
</body>
</html>`
}

View File

@ -20,6 +20,14 @@ export function parseRequest(req: IncomingMessage) {
creatorName,
creatorUsername,
creatorAvatarUrl,
// Challenge attributes:
challengerAmount,
challengerOutcome,
creatorAmount,
creatorOutcome,
acceptedName,
acceptedAvatarUrl,
} = query || {}
if (Array.isArray(fontSize)) {
@ -67,6 +75,12 @@ export function parseRequest(req: IncomingMessage) {
creatorName: getString(creatorName) || 'Manifold Markets',
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
challengerAmount: getString(challengerAmount) || '',
challengerOutcome: getString(challengerOutcome) || '',
creatorAmount: getString(creatorAmount) || '',
creatorOutcome: getString(creatorOutcome) || '',
acceptedName: getString(acceptedName) || '',
acceptedAvatarUrl: getString(acceptedAvatarUrl) || '',
}
parsedRequest.images = getDefaultImages(parsedRequest.images)
return parsedRequest

View File

@ -126,7 +126,7 @@ export function getHtml(parsedReq: ParsedRequest) {
</div>
</div>
<!-- Mantic logo -->
<!-- Manifold logo -->
<div class="absolute right-24 top-8">
<a class="flex flex-row gap-3" href="/"
><img

View File

@ -1,21 +1,28 @@
export type FileType = "png" | "jpeg";
export type Theme = "light" | "dark";
export type FileType = 'png' | 'jpeg'
export type Theme = 'light' | 'dark'
export interface ParsedRequest {
fileType: FileType;
text: string;
theme: Theme;
md: boolean;
fontSize: string;
images: string[];
widths: string[];
heights: string[];
fileType: FileType
text: string
theme: Theme
md: boolean
fontSize: string
images: string[]
widths: string[]
heights: string[]
// Attributes for Manifold card:
question: string;
probability: string;
metadata: string;
creatorName: string;
creatorUsername: string;
creatorAvatarUrl: string;
question: string
probability: string
metadata: string
creatorName: string
creatorUsername: string
creatorAvatarUrl: string
// Challenge attributes:
challengerAmount: string
challengerOutcome: string
creatorAmount: string
creatorOutcome: string
acceptedName: string
acceptedAvatarUrl: string
}

View File

@ -1,36 +1,38 @@
import { IncomingMessage, ServerResponse } from "http";
import { parseRequest } from "./_lib/parser";
import { getScreenshot } from "./_lib/chromium";
import { getHtml } from "./_lib/template";
import { IncomingMessage, ServerResponse } from 'http'
import { parseRequest } from './_lib/parser'
import { getScreenshot } from './_lib/chromium'
import { getHtml } from './_lib/template'
import { getChallengeHtml } from './_lib/challenge-template'
const isDev = !process.env.AWS_REGION;
const isHtmlDebug = process.env.OG_HTML_DEBUG === "1";
const isDev = !process.env.AWS_REGION
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1'
export default async function handler(
req: IncomingMessage,
res: ServerResponse
) {
try {
const parsedReq = parseRequest(req);
const html = getHtml(parsedReq);
const parsedReq = parseRequest(req)
let html = getHtml(parsedReq)
if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq)
if (isHtmlDebug) {
res.setHeader("Content-Type", "text/html");
res.end(html);
return;
res.setHeader('Content-Type', 'text/html')
res.end(html)
return
}
const { fileType } = parsedReq;
const file = await getScreenshot(html, fileType, isDev);
res.statusCode = 200;
res.setHeader("Content-Type", `image/${fileType}`);
const { fileType } = parsedReq
const file = await getScreenshot(html, fileType, isDev)
res.statusCode = 200
res.setHeader('Content-Type', `image/${fileType}`)
res.setHeader(
"Cache-Control",
'Cache-Control',
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
);
res.end(file);
)
res.end(file)
} catch (e) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html");
res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
console.error(e);
res.statusCode = 500
res.setHeader('Content-Type', 'text/html')
res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>')
console.error(e)
}
}

View File

@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import Head from 'next/head'
import { Challenge } from 'common/challenge'
export type OgCardProps = {
question: string
@ -10,7 +11,16 @@ export type OgCardProps = {
creatorAvatarUrl?: string
}
function buildCardUrl(props: OgCardProps) {
function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
const {
creatorAmount,
acceptances,
acceptorAmount,
creatorOutcome,
acceptorOutcome,
} = challenge || {}
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
const probabilityParam =
props.probability === undefined
? ''
@ -20,6 +30,12 @@ function buildCardUrl(props: OgCardProps) {
? ''
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
const challengeUrlParams = challenge
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
: ''
// URL encode each of the props, then add them as query params
return (
`https://manifold-og-image.vercel.app/m.png` +
@ -28,7 +44,8 @@ function buildCardUrl(props: OgCardProps) {
`&metadata=${encodeURIComponent(props.metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` +
creatorAvatarUrlParam +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
challengeUrlParams
)
}
@ -38,8 +55,9 @@ export function SEO(props: {
url?: string
children?: ReactNode
ogCardProps?: OgCardProps
challenge?: Challenge
}) {
const { title, description, url, children, ogCardProps } = props
const { title, description, url, children, ogCardProps, challenge } = props
return (
<Head>
@ -71,13 +89,13 @@ export function SEO(props: {
<>
<meta
property="og:image"
content={buildCardUrl(ogCardProps)}
content={buildCardUrl(ogCardProps, challenge)}
key="image1"
/>
<meta name="twitter:card" content="summary_large_image" key="card" />
<meta
name="twitter:image"
content={buildCardUrl(ogCardProps)}
content={buildCardUrl(ogCardProps, challenge)}
key="image2"
/>
</>

View File

@ -16,8 +16,7 @@ import {
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet } from 'web/lib/firebase/api'
import { sellShares } from 'web/lib/firebase/api'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import {
@ -351,7 +350,7 @@ function BuyPanel(props: {
{user && (
<button
className={clsx(
'btn flex-1',
'btn mb-2 flex-1',
betDisabled
? 'btn-disabled'
: outcome === 'YES'

View File

@ -5,8 +5,16 @@ export function Button(props: {
className?: string
onClick?: () => void
children?: ReactNode
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
color?:
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {
@ -26,6 +34,7 @@ export function Button(props: {
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base',
'2xl': 'px-6 py-3 text-xl',
}[size]
return (
@ -39,8 +48,9 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
color === 'gradient' &&
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
className

View File

@ -0,0 +1,125 @@
import { User } from 'common/user'
import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react'
import { SignUpPrompt } from 'web/components/sign-up-prompt'
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { formatMoney } from 'common/util/format'
import { Button } from 'web/components/button'
import clsx from 'clsx'
export function AcceptChallengeButton(props: {
user: User | null | undefined
contract: Contract
challenge: Challenge
}) {
const { user, challenge, contract } = props
const [open, setOpen] = useState(false)
const [errorText, setErrorText] = useState('')
const [loading, setLoading] = useState(false)
const { acceptorAmount, creatorAmount } = challenge
useEffect(() => {
setErrorText('')
}, [open])
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
const iAcceptChallenge = () => {
setLoading(true)
if (user.id === challenge.creatorId) {
setErrorText('You cannot accept your own challenge!')
setLoading(false)
return
}
acceptChallenge({
contractId: contract.id,
challengeSlug: challenge.slug,
outcomeType: contract.outcomeType,
closeTime: contract.closeTime,
})
.then((r) => {
console.log('accepted challenge. Result:', r)
setLoading(false)
})
.catch((e) => {
setLoading(false)
if (e instanceof APIError) {
setErrorText(e.toString())
} else {
console.error(e)
setErrorText('Error accepting challenge')
}
})
}
return (
<>
<Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}>
<Col className="gap-4 rounded-md bg-white px-8 py-6">
<Col className={'gap-4'}>
<div className={'flex flex-row justify-start '}>
<Title text={"So you're in?"} className={'!my-2'} />
</div>
<Col className="w-full items-center justify-start gap-2">
<Row className={'w-full justify-start gap-20'}>
<span className={'min-w-[4rem] font-bold'}>Cost to you:</span>{' '}
<span className={'text-red-500'}>
{formatMoney(acceptorAmount)}
</span>
</Row>
<Col className={'w-full items-center justify-start'}>
<Row className={'w-full justify-start gap-10'}>
<span className={'min-w-[4rem] font-bold'}>
Potential payout:
</span>{' '}
<Row className={'items-center justify-center'}>
<span className={'text-primary'}>
{formatMoney(creatorAmount + acceptorAmount)}
</span>
</Row>
</Row>
</Col>
</Col>
<Row className={'mt-4 justify-end gap-4'}>
<Button
color={'gray'}
disabled={loading}
onClick={() => setOpen(false)}
className={clsx('whitespace-nowrap')}
>
I'm out
</Button>
<Button
color={'indigo'}
disabled={loading}
onClick={() => iAcceptChallenge()}
className={clsx('min-w-[6rem] whitespace-nowrap')}
>
I'm in
</Button>
</Row>
<Row>
<span className={'text-error'}>{errorText}</span>
</Row>
</Col>
</Col>
</Modal>
{challenge.creatorId != user.id && (
<Button
color="gradient"
size="2xl"
onClick={() => setOpen(true)}
className={clsx('whitespace-nowrap')}
>
Accept bet
</Button>
)}
</>
)
}

View File

@ -0,0 +1,255 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Title } from '../title'
import { User } from 'common/user'
import { Modal } from 'web/components/layout/modal'
import { Button } from '../button'
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { BinaryContract } from 'common/contract'
import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import toast from 'react-hot-toast'
type challengeInfo = {
amount: number
expiresTime: number | null
message: string
outcome: 'YES' | 'NO' | number
acceptorAmount: number
}
export function CreateChallengeButton(props: {
user: User | null | undefined
contract: BinaryContract
}) {
const { user, contract } = props
const [open, setOpen] = useState(false)
const [challengeSlug, setChallengeSlug] = useState('')
return (
<>
<Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}>
<Col className="gap-4 rounded-md bg-white px-8 py-6">
{/*// add a sign up to challenge button?*/}
{user && (
<CreateChallengeForm
user={user}
contract={contract}
onCreate={async (newChallenge) => {
const challenge = await createChallenge({
creator: user,
creatorAmount: newChallenge.amount,
expiresTime: newChallenge.expiresTime,
message: newChallenge.message,
acceptorAmount: newChallenge.acceptorAmount,
outcome: newChallenge.outcome,
contract: contract,
})
challenge && setChallengeSlug(getChallengeUrl(challenge))
}}
challengeSlug={challengeSlug}
/>
)}
</Col>
</Modal>
<button
onClick={() => setOpen(true)}
className="btn btn-outline mb-4 max-w-xs whitespace-nowrap normal-case"
>
Challenge a friend
</button>
</>
)
}
function CreateChallengeForm(props: {
user: User
contract: BinaryContract
onCreate: (m: challengeInfo) => Promise<void>
challengeSlug: string
}) {
const { user, onCreate, contract, challengeSlug } = props
const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week'
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
outcome: 'YES',
amount: 100,
acceptorAmount: 100,
message: defaultMessage,
})
useEffect(() => {
setError('')
}, [challengeInfo])
return (
<>
{!finishedCreating && (
<form
onSubmit={(e) => {
e.preventDefault()
if (user.balance < challengeInfo.amount) {
setError('You do not have enough mana to create this challenge')
return
}
setIsCreating(true)
onCreate(challengeInfo).finally(() => setIsCreating(false))
setFinishedCreating(true)
}}
>
<Title className="!mt-2" text="Challenge a friend to bet " />
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
<div>You'll bet:</div>
<Row
className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
}
>
<Col>
<div className="relative">
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$
</span>
<input
className="input input-bordered w-32 pl-10"
type="number"
min={1}
value={challengeInfo.amount}
onChange={(e) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
amount: parseInt(e.target.value),
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: parseInt(e.target.value),
}
})
}
/>
</div>
</Col>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gradient'}
className={'opacity-80'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
}
>
<SwitchVerticalIcon className={'h-4 w-4'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'w-32 sm:mr-1'}>
{editingAcceptorAmount ? (
<Col>
<div className="relative">
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$
</span>
<input
className="input input-bordered w-32 pl-10"
type="number"
min={1}
value={challengeInfo.acceptorAmount}
onChange={(e) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: parseInt(e.target.value),
}
})
}
/>
</div>
</Col>
) : (
<span className="ml-1 font-bold">
{formatMoney(challengeInfo.acceptorAmount)}
</span>
)}
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</div>
<Row
className={clsx(
'mt-8',
!editingAcceptorAmount ? 'justify-between' : 'justify-end'
)}
>
{!editingAcceptorAmount && (
<Button
color={'gray-white'}
onClick={() => setEditingAcceptorAmount(!editingAcceptorAmount)}
>
Edit
</Button>
)}
<Button
type="submit"
color={'indigo'}
className={clsx(
'whitespace-nowrap drop-shadow-md',
isCreating ? 'disabled' : ''
)}
>
Continue
</Button>
</Row>
<Row className={'text-error'}>{error} </Row>
</form>
)}
{finishedCreating && (
<>
<Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div>
<button
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}>
See your other
<SiteLink className={'underline'} href={'/challenges'}>
challenges
</SiteLink>
</Row>
</>
)}
</>
)
}

View File

@ -0,0 +1,36 @@
import { Contract } from 'common/contract'
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { richTextToString } from 'common/util/parse'
import { contractTextDetails } from 'web/components/contract/contract-details'
export const getOpenGraphProps = (contract: Contract) => {
const {
resolution,
question,
creatorName,
creatorUsername,
outcomeType,
creatorAvatarUrl,
description: desc,
} = contract
const probPercent =
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
const description = resolution
? `Resolved ${resolution}. ${stringDesc}`
: probPercent
? `${probPercent} chance. ${stringDesc}`
: stringDesc
return {
question,
probability: probPercent,
metadata: contractTextDetails(contract),
creatorName,
creatorUsername,
creatorAvatarUrl,
description,
}
}

View File

@ -1,4 +1,4 @@
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { contractUrl, tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { ContractProbGraph } from './contract-prob-graph'
@ -8,8 +8,8 @@ import { Linkify } from '../linkify'
import clsx from 'clsx'
import {
FreeResponseResolutionOrChance,
BinaryResolutionOrChance,
FreeResponseResolutionOrChance,
NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from './contract-card'
@ -19,8 +19,13 @@ import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details'
import { ShareMarket } from '../share-market'
import { NumericGraph } from './numeric-graph'
import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button'
import React from 'react'
import { copyToClipboard } from 'web/lib/util/copy'
import toast from 'react-hot-toast'
import { LinkIcon } from '@heroicons/react/outline'
import { CHALLENGES_ENABLED } from 'common/challenge'
export const ContractOverview = (props: {
contract: Contract
@ -32,8 +37,10 @@ export const ContractOverview = (props: {
const user = useUser()
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const showChallenge = user && isBinary && !resolution && CHALLENGES_ENABLED
return (
<Col className={clsx('mb-6', className)}>
@ -116,13 +123,47 @@ export const ContractOverview = (props: {
<AnswersGraph contract={contract} bets={bets} />
)}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
{(contract.description || isCreator) && <Spacer h={6} />}
{isCreator && <ShareMarket className="px-2" contract={contract} />}
{/* {(contract.description || isCreator) && <Spacer h={6} />} */}
<ContractDescription
className="px-2"
contract={contract}
isCreator={isCreator}
/>
{/*<Row className="mx-4 mt-4 hidden justify-around sm:block">*/}
{/* {showChallenge && (*/}
{/* <Col className="gap-3">*/}
{/* <div className="text-lg">⚔️ Challenge a friend ⚔️</div>*/}
{/* <CreateChallengeButton user={user} contract={contract} />*/}
{/* </Col>*/}
{/* )}*/}
{/* {isCreator && (*/}
{/* <Col className="gap-3">*/}
{/* <div className="text-lg">Share your market</div>*/}
{/* <ShareMarketButton contract={contract} />*/}
{/* </Col>*/}
{/* )}*/}
{/*</Row>*/}
<Row className="mx-4 mt-6 block justify-around">
{showChallenge && (
<Col className="gap-3">
<CreateChallengeButton user={user} contract={contract} />
</Col>
)}
{isCreator && (
<Col className="gap-3">
<button
onClick={() => {
copyToClipboard(contractUrl(contract))
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Share market
</button>
</Col>
)}
</Row>
</Col>
)
}

View File

@ -2,7 +2,6 @@ import React, { Fragment } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
@ -14,6 +13,8 @@ export function CopyLinkButton(props: {
tracking?: string
buttonClassName?: string
toastClassName?: string
icon?: React.ComponentType<{ className?: string }>
label?: string
}) {
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props

View File

@ -26,7 +26,10 @@ export function ContractActivity(props: {
const contract = useContractWithPreload(props.contract) ?? props.contract
const comments = props.comments
const updatedBets = useBets(contract.id)
const updatedBets = useBets(contract.id, {
filterChallenges: false,
filterRedemptions: true,
})
const bets = (updatedBets ?? props.bets).filter(
(bet) => !bet.isRedemption && bet.amount !== 0
)

View File

@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react'
import React, { Fragment, useEffect } from 'react'
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
import { JoinSpans } from 'web/components/join-spans'
import { UserLink } from '../user-page'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
export function FeedBet(props: {
contract: Contract
@ -79,7 +82,15 @@ export function BetStatusText(props: {
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
const { amount, outcome, createdTime } = bet
const { amount, outcome, createdTime, challengeSlug } = bet
const [challenge, setChallenge] = React.useState<Challenge>()
useEffect(() => {
if (challengeSlug) {
getChallenge(challengeSlug, contract.id).then((c) => {
setChallenge(c)
})
}
}, [challengeSlug, contract.id])
const bought = amount >= 0 ? 'bought' : 'sold'
const outOfTotalAmount =
@ -133,6 +144,14 @@ export function BetStatusText(props: {
{fromProb === toProb
? `at ${fromProb}`
: `from ${fromProb} to ${toProb}`}
{challengeSlug && (
<SiteLink
href={challenge ? getChallengeUrl(challenge) : ''}
className={'mx-1'}
>
[challenge]
</SiteLink>
)}
</>
)}
<RelativeTimestamp time={createdTime} />

View File

@ -29,6 +29,7 @@ import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out
@ -60,26 +61,50 @@ function getMoreNavigation(user?: User | null) {
}
if (!user) {
return [
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
if (CHALLENGES_ENABLED)
return [
{ name: 'Challenges', href: '/challenges' },
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
else
return [
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
}
return [
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
if (CHALLENGES_ENABLED)
return [
{ name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
else
return [
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
}
const signedOutNavigation = [
@ -119,6 +144,14 @@ function getMoreMobileNav() {
return [
...(IS_PRIVATE_MANIFOLD
? []
: CHALLENGES_ENABLED
? [
{ name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
]
: [
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },

View File

@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo(
function PortfolioValueSection(props: {
portfolioHistory: PortfolioMetrics[]
disableSelector?: boolean
}) {
const { portfolioHistory } = props
const { portfolioHistory, disableSelector } = props
const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
@ -30,7 +31,9 @@ export const PortfolioValueSection = memo(
<div>
<Row className="gap-8">
<div className="mb-4 w-full">
<Col>
<Col
className={disableSelector ? 'items-center justify-center' : ''}
>
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">
{formatMoney(
@ -40,16 +43,18 @@ export const PortfolioValueSection = memo(
</div>
</Col>
</div>
<select
className="select select-bordered self-start"
onChange={(e) => {
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">{allTimeLabel}</option>
<option value="weekly">7 days</option>
<option value="daily">24 hours</option>
</select>
{!disableSelector && (
<select
className="select select-bordered self-start"
onChange={(e) => {
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">{allTimeLabel}</option>
<option value="weekly">7 days</option>
<option value="daily">24 hours</option>
</select>
)}
</Row>
<PortfolioValueGraph
portfolioHistory={portfolioHistory}

View File

@ -0,0 +1,18 @@
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button'
export function ShareMarketButton(props: { contract: Contract }) {
const { contract } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return (
<CopyLinkButton
url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
)
}

View File

@ -1,28 +0,0 @@
import clsx from 'clsx'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col'
import { Row } from './layout/row'
export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return (
<Col className={clsx(className, 'gap-3')}>
<div>Share your market</div>
<Row className="mb-6 items-center">
<CopyLinkButton
url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Row>
</Col>
)
}

View File

@ -2,16 +2,20 @@ import React from 'react'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
import { Button } from './button'
export function SignUpPrompt() {
export function SignUpPrompt(props: { label?: string; className?: string }) {
const { label, className } = props
const user = useUser()
return user === null ? (
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600"
<Button
onClick={withTracking(firebaseLogin, 'sign up to bet')}
className={className}
size="lg"
color="gradient"
>
Sign up to bet!
</button>
{label ?? 'Sign up to bet!'}
</Button>
) : null
}

View File

@ -9,12 +9,26 @@ import {
} from 'web/lib/firebase/bets'
import { LimitBet } from 'common/bet'
export const useBets = (contractId: string) => {
export const useBets = (
contractId: string,
options?: { filterChallenges: boolean; filterRedemptions: boolean }
) => {
const [bets, setBets] = useState<Bet[] | undefined>()
useEffect(() => {
if (contractId) return listenForBets(contractId, setBets)
}, [contractId])
if (contractId)
return listenForBets(contractId, (bets) => {
if (options)
setBets(
bets.filter(
(bet) =>
(options.filterChallenges ? !bet.challengeSlug : true) &&
(options.filterRedemptions ? !bet.isRedemption : true)
)
)
else setBets(bets)
})
}, [contractId, options])
return bets
}

View File

@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users'
export const useSaveReferral = (
user?: User | null,
options?: {
defaultReferrer?: string
defaultReferrerUsername?: string
contractId?: string
groupId?: string
}
@ -18,7 +18,7 @@ export const useSaveReferral = (
referrer?: string
}
const referrerOrDefault = referrer || options?.defaultReferrer
const referrerOrDefault = referrer || options?.defaultReferrerUsername
if (!user && router.isReady && referrerOrDefault) {
writeReferralInfo(referrerOrDefault, {

View File

@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
import { doc, DocumentData } from 'firebase/firestore'
import { doc, DocumentData, where } from 'firebase/firestore'
import { PrivateUser } from 'common/user'
import {
getUser,

View File

@ -81,6 +81,10 @@ export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}
export function acceptChallenge(params: any) {
return call(getFunctionUrl('acceptchallenge'), 'POST', params)
}
export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
}

View File

@ -0,0 +1,150 @@
import {
collectionGroup,
doc,
getDoc,
orderBy,
query,
setDoc,
where,
} from 'firebase/firestore'
import { Challenge } from 'common/challenge'
import { customAlphabet } from 'nanoid'
import { coll, listenForValue, listenForValues } from './utils'
import { useEffect, useState } from 'react'
import { User } from 'common/user'
import { db } from './init'
import { Contract } from 'common/contract'
import { ENV_CONFIG } from 'common/envs/constants'
export const challenges = (contractId: string) =>
coll<Challenge>(`contracts/${contractId}/challenges`)
export function getChallengeUrl(challenge: Challenge) {
return `https://${ENV_CONFIG.domain}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}`
}
export async function createChallenge(data: {
creator: User
outcome: 'YES' | 'NO' | number
contract: Contract
creatorAmount: number
acceptorAmount: number
expiresTime: number | null
message: string
}) {
const {
creator,
creatorAmount,
expiresTime,
message,
contract,
outcome,
acceptorAmount,
} = data
// At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years
// See https://zelark.github.io/nano-id-cc/
const nanoid = customAlphabet(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
8
)
const slug = nanoid()
if (creatorAmount <= 0 || isNaN(creatorAmount) || !isFinite(creatorAmount))
return null
const challenge: Challenge = {
slug,
creatorId: creator.id,
creatorUsername: creator.username,
creatorName: creator.name,
creatorAvatarUrl: creator.avatarUrl,
creatorAmount,
creatorOutcome: outcome.toString(),
creatorOutcomeProb: creatorAmount / (creatorAmount + acceptorAmount),
acceptorOutcome: outcome === 'YES' ? 'NO' : 'YES',
acceptorAmount,
contractSlug: contract.slug,
contractId: contract.id,
contractQuestion: contract.question,
contractCreatorUsername: contract.creatorUsername,
createdTime: Date.now(),
expiresTime,
maxUses: 1,
acceptedByUserIds: [],
acceptances: [],
isResolved: false,
message,
}
await setDoc(doc(challenges(contract.id), slug), challenge)
return challenge
}
// TODO: This required an index, make sure to also set up in prod
function listUserChallenges(fromId?: string) {
return query(
collectionGroup(db, 'challenges'),
where('creatorId', '==', fromId),
orderBy('createdTime', 'desc')
)
}
function listChallenges() {
return query(collectionGroup(db, 'challenges'))
}
export const useAcceptedChallenges = () => {
const [links, setLinks] = useState<Challenge[]>([])
useEffect(() => {
listenForValues(listChallenges(), (challenges: Challenge[]) => {
setLinks(
challenges
.sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime)
.filter((challenge) => challenge.acceptedByUserIds.length > 0)
)
})
}, [])
return links
}
export function listenForChallenge(
slug: string,
contractId: string,
setLinks: (challenge: Challenge | null) => void
) {
return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks)
}
export function useChallenge(slug: string, contractId: string | undefined) {
const [challenge, setChallenge] = useState<Challenge | null>()
useEffect(() => {
if (slug && contractId) {
listenForChallenge(slug, contractId, setChallenge)
}
}, [contractId, slug])
return challenge
}
export function listenForUserChallenges(
fromId: string | undefined,
setLinks: (links: Challenge[]) => void
) {
return listenForValues<Challenge>(listUserChallenges(fromId), setLinks)
}
export const useUserChallenges = (fromId: string) => {
const [links, setLinks] = useState<Challenge[]>([])
useEffect(() => {
return listenForUserChallenges(fromId, setLinks)
}, [fromId])
return links
}
export const getChallenge = async (slug: string, contractId: string) => {
const challenge = await getDoc(doc(challenges(contractId), slug))
return challenge.data() as Challenge
}

View File

@ -35,6 +35,13 @@ export function contractPath(contract: Contract) {
return `/${contract.creatorUsername}/${contract.slug}`
}
export function contractPathWithoutContract(
creatorUsername: string,
slug: string
) {
return `/${creatorUsername}/${slug}`
}
export function homeContractPath(contract: Contract) {
return `/home?c=${contract.slug}`
}

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Spacer } from 'web/components/layout/spacer'
import {
Contract,
getContractFromSlug,
tradingAllowed,
getBinaryProbPercent,
} from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page'
@ -21,26 +21,29 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format'
import { ContractTabs } from 'web/components/contract/contract-tabs'
import { contractTextDetails } from 'web/components/contract/contract-details'
import { useWindowSize } from 'web/hooks/use-window-size'
import Confetti from 'react-confetti'
import { NumericBetPanel } from '../../components/numeric-bet-panel'
import { NumericResolutionPanel } from '../../components/numeric-resolution-panel'
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { richTextToString } from 'common/util/parse'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
import { User } from 'common/user'
import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title'
import { FeedBet } from 'web/components/feed/feed-bets'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -153,7 +156,7 @@ export function ContractPageContent(
const ogCardProps = getOpenGraphProps(contract)
useSaveReferral(user, {
defaultReferrer: contract.creatorUsername,
defaultReferrerUsername: contract.creatorUsername,
contractId: contract.id,
})
@ -208,7 +211,10 @@ export function ContractPageContent(
</button>
)}
<ContractOverview contract={contract} bets={bets} />
<ContractOverview
contract={contract}
bets={bets.filter((b) => !b.challengeSlug)}
/>
{isNumeric && (
<AlertBox
@ -258,34 +264,125 @@ export function ContractPageContent(
)
}
const getOpenGraphProps = (contract: Contract) => {
const {
resolution,
question,
creatorName,
creatorUsername,
outcomeType,
creatorAvatarUrl,
description: desc,
} = contract
const probPercent =
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const description = resolution
? `Resolved ${resolution}. ${stringDesc}`
: probPercent
? `${probPercent} chance. ${stringDesc}`
: stringDesc
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
return {
question,
probability: probPercent,
metadata: contractTextDetails(contract),
creatorName,
creatorUsername,
creatorAvatarUrl,
description,
}
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -0,0 +1,403 @@
import React, { useEffect, useState } from 'react'
import Confetti from 'react-confetti'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { DOMAIN } from 'common/envs/constants'
import { Col } from 'web/components/layout/col'
import { SiteLink } from 'web/components/site-link'
import { Spacer } from 'web/components/layout/spacer'
import { Row } from 'web/components/layout/row'
import { Challenge } from 'common/challenge'
import {
getChallenge,
getChallengeUrl,
useChallenge,
} from 'web/lib/firebase/challenges'
import { getUserByUsername } from 'web/lib/firebase/users'
import { User } from 'common/user'
import { Page } from 'web/components/page'
import { useUser, useUserById } from 'web/hooks/use-user'
import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { BinaryOutcomeLabel } from 'web/components/outcome-label'
import { formatMoney } from 'common/util/format'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { SEO } from 'web/components/SEO'
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
import Custom404 from 'web/pages/404'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { BinaryContract } from 'common/contract'
import { Title } from 'web/components/title'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
params: { username: string; contractSlug: string; challengeSlug: string }
}) {
const { username, contractSlug, challengeSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null
const user = (await getUserByUsername(username)) || null
const bets = contract?.id ? await listAllBets(contract.id) : []
const challenge = contract?.id
? await getChallenge(challengeSlug, contract.id)
: null
return {
props: {
contract,
user,
slug: contractSlug,
challengeSlug,
bets,
challenge,
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
export default function ChallengePage(props: {
contract: BinaryContract | null
user: User
slug: string
bets: Bet[]
challenge: Challenge | null
challengeSlug: string
}) {
props = usePropz(props, getStaticPropz) ?? {
contract: null,
user: null,
challengeSlug: '',
bets: [],
challenge: null,
slug: '',
}
const contract = (useContractWithPreload(props.contract) ??
props.contract) as BinaryContract
const challenge =
useChallenge(props.challengeSlug, contract?.id) ?? props.challenge
const { user, bets } = props
const currentUser = useUser()
useSaveReferral(currentUser, {
defaultReferrerUsername: challenge?.creatorUsername,
})
if (!contract || !challenge) return <Custom404 />
const ogCardProps = getOpenGraphProps(contract)
ogCardProps.creatorUsername = challenge.creatorUsername
ogCardProps.creatorName = challenge.creatorName
ogCardProps.creatorAvatarUrl = challenge.creatorAvatarUrl
return (
<Page>
<SEO
title={ogCardProps.question}
description={ogCardProps.description}
url={getChallengeUrl(challenge).replace('https://', '')}
ogCardProps={ogCardProps}
challenge={challenge}
/>
{challenge.acceptances.length >= challenge.maxUses ? (
<ClosedChallengeContent
contract={contract}
challenge={challenge}
creator={user}
/>
) : (
<OpenChallengeContent
user={currentUser}
contract={contract}
challenge={challenge}
creator={user}
bets={bets}
/>
)}
<FAQ />
</Page>
)
}
function FAQ() {
const [toggleWhatIsThis, setToggleWhatIsThis] = useState(false)
const [toggleWhatIsMana, setToggleWhatIsMana] = useState(false)
return (
<Col className={'items-center gap-4 p-2 md:p-6 lg:items-start'}>
<Row className={'text-xl text-indigo-700'}>FAQ</Row>
<Row className={'text-lg text-indigo-700'}>
<span
className={'mx-2 cursor-pointer'}
onClick={() => setToggleWhatIsThis(!toggleWhatIsThis)}
>
{toggleWhatIsThis ? '-' : '+'}
What is this?
</span>
</Row>
{toggleWhatIsThis && (
<Row className={'mx-4'}>
<span>
This is a challenge bet, or a bet offered from one person to another
that is only realized if both parties agree. You can agree to the
challenge (if it's open) or create your own from a market page. See
more markets{' '}
<SiteLink className={'font-bold'} href={'/home'}>
here.
</SiteLink>
</span>
</Row>
)}
<Row className={'text-lg text-indigo-700'}>
<span
className={'mx-2 cursor-pointer'}
onClick={() => setToggleWhatIsMana(!toggleWhatIsMana)}
>
{toggleWhatIsMana ? '-' : '+'}
What is M$?
</span>
</Row>
{toggleWhatIsMana && (
<Row className={'mx-4'}>
Mana (M$) is the play-money used by our platform to keep track of your
bets. It's completely free for you and your friends to get started!
</Row>
)}
</Col>
)
}
function ClosedChallengeContent(props: {
contract: BinaryContract
challenge: Challenge
creator: User
}) {
const { contract, challenge, creator } = props
const { resolution, question } = contract
const {
acceptances,
creatorAmount,
creatorOutcome,
acceptorOutcome,
acceptorAmount,
} = challenge
const user = useUserById(acceptances[0].userId)
const [showConfetti, setShowConfetti] = useState(false)
const { width, height } = useWindowSize()
useEffect(() => {
if (acceptances.length === 0) return
if (acceptances[0].createdTime > Date.now() - 1000 * 60)
setShowConfetti(true)
}, [acceptances])
const creatorWon = resolution === creatorOutcome
const href = `https://${DOMAIN}${contractPath(contract)}`
if (!user) return <LoadingIndicator />
const winner = (creatorWon ? creator : user).name
return (
<>
{showConfetti && (
<Confetti
width={width ?? 500}
height={height ?? 500}
confettiSource={{
x: ((width ?? 500) - 200) / 2,
y: 0,
w: 200,
h: 0,
}}
recycle={false}
initialVelocityY={{ min: 1, max: 3 }}
numberOfPieces={200}
/>
)}
<Col className=" w-full items-center justify-center rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8 ">
{resolution ? (
<>
<Title className="!mt-0" text={`🥇 ${winner} wins the bet 🥇`} />
<SiteLink href={href} className={'mb-8 text-xl'}>
{question}
</SiteLink>
</>
) : (
<SiteLink href={href} className={'mb-8'}>
<span className="text-3xl text-indigo-700">{question}</span>
</SiteLink>
)}
<Col
className={'w-full content-between justify-between gap-1 sm:flex-row'}
>
<UserBetColumn
challenger={creator}
outcome={creatorOutcome}
amount={creatorAmount}
isResolved={!!resolution}
/>
<Col className="items-center justify-center py-8 text-2xl sm:text-4xl">
VS
</Col>
<UserBetColumn
challenger={user?.id === creator.id ? undefined : user}
outcome={acceptorOutcome}
amount={acceptorAmount}
isResolved={!!resolution}
/>
</Col>
<Spacer h={3} />
{/* <Row className="mt-8 items-center">
<span className='mr-4'>Share</span> <CopyLinkButton url={window.location.href} />
</Row> */}
</Col>
</>
)
}
function OpenChallengeContent(props: {
contract: BinaryContract
challenge: Challenge
creator: User
user: User | null | undefined
bets: Bet[]
}) {
const { contract, challenge, creator, user } = props
const { question } = contract
const {
creatorAmount,
creatorId,
creatorOutcome,
acceptorAmount,
acceptorOutcome,
} = challenge
const href = `https://${DOMAIN}${contractPath(contract)}`
return (
<Col className="items-center">
<Col className="h-full items-center justify-center rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<SiteLink href={href} className={'mb-8'}>
<span className="text-3xl text-indigo-700">{question}</span>
</SiteLink>
<Col
className={
'h-full max-h-[50vh] w-full content-between justify-between gap-1 sm:flex-row'
}
>
<UserBetColumn
challenger={creator}
outcome={creatorOutcome}
amount={creatorAmount}
/>
<Col className="items-center justify-center py-4 text-2xl sm:py-8 sm:text-4xl">
VS
</Col>
<UserBetColumn
challenger={user?.id === creatorId ? undefined : user}
outcome={acceptorOutcome}
amount={acceptorAmount}
/>
</Col>
<Spacer h={3} />
<Row className={'my-4 text-center text-gray-500'}>
<span>
{`${creator.name} will bet ${formatMoney(
creatorAmount
)} on ${creatorOutcome} if you bet ${formatMoney(
acceptorAmount
)} on ${acceptorOutcome}. Whoever is right will get `}
<span className="mr-1 font-bold ">
{formatMoney(creatorAmount + acceptorAmount)}
</span>
total.
</span>
</Row>
<Row className="my-4 w-full items-center justify-center">
<AcceptChallengeButton
user={user}
contract={contract}
challenge={challenge}
/>
</Row>
</Col>
</Col>
)
}
const userCol = (challenger: User) => (
<Col className={'mb-2 w-full items-center justify-center gap-2'}>
<UserLink
className={'text-2xl'}
name={challenger.name}
username={challenger.username}
/>
<Avatar
size={24}
avatarUrl={challenger.avatarUrl}
username={challenger.username}
/>
</Col>
)
function UserBetColumn(props: {
challenger: User | null | undefined
outcome: string
amount: number
isResolved?: boolean
}) {
const { challenger, outcome, amount, isResolved } = props
return (
<Col className="w-full items-start justify-center gap-1">
{challenger ? (
userCol(challenger)
) : (
<Col className={'mb-2 w-full items-center justify-center gap-2'}>
<span className={'text-2xl'}>You</span>
<Avatar
className={'h-[7.25rem] w-[7.25rem]'}
avatarUrl={undefined}
username={undefined}
/>
</Col>
)}
<Row className={'w-full items-center justify-center'}>
<span className={'text-lg'}>
{isResolved ? 'had bet' : challenger ? '' : ''}
</span>
</Row>
<Row className={'w-full items-center justify-center'}>
<span className={'text-lg'}>
<span className="bold text-2xl">{formatMoney(amount)}</span>
{' on '}
<span className="bold text-2xl">
<BinaryOutcomeLabel outcome={outcome as any} />
</span>{' '}
</span>
</Row>
</Col>
)
}

View File

@ -0,0 +1,300 @@
import clsx from 'clsx'
import React from 'react'
import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useUser } from 'web/hooks/use-user'
import { fromNow } from 'web/lib/util/time'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
getChallengeUrl,
useAcceptedChallenges,
useUserChallenges,
} from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
import { Tabs } from 'web/components/layout/tabs'
import { SiteLink } from 'web/components/site-link'
import { UserLink } from 'web/components/user-page'
import { Avatar } from 'web/components/avatar'
import Router from 'next/router'
import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
import { Button } from 'web/components/button'
import { ClipboardCopyIcon, QrcodeIcon } from '@heroicons/react/outline'
import { copyToClipboard } from 'web/lib/util/copy'
import toast from 'react-hot-toast'
import { Modal } from 'web/components/layout/modal'
import { QRCode } from 'web/components/qr-code'
dayjs.extend(customParseFormat)
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
const amountClass = columnClass + ' max-w-[75px] font-bold'
export default function ChallengesListPage() {
const user = useUser()
const userChallenges = useUserChallenges(user?.id ?? '')
const challenges = useAcceptedChallenges()
const userTab = user
? [
{
content: <YourChallengesTable links={userChallenges} />,
title: 'Your Challenges',
},
]
: []
const publicTab = [
{
content: <PublicChallengesTable links={challenges} />,
title: 'Public Challenges',
},
]
return (
<Page>
<SEO
title="Challenges"
description="Challenge your friends to a bet!"
url="/send"
/>
<Col className="w-full px-8">
<Row className="items-center justify-between">
<Title text="Challenges" />
</Row>
<p>Find or create a question to challenge someone to a bet.</p>
<Tabs tabs={[...userTab, ...publicTab]} />
</Col>
</Page>
)
}
function YourChallengesTable(props: { links: Challenge[] }) {
const { links } = props
return links.length == 0 ? (
<p>There aren't currently any challenges.</p>
) : (
<div className="overflow-scroll">
<table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th className={amountClass}>Amount</th>
<th
className={clsx(
columnClass,
'text-center sm:pl-10 sm:text-start'
)}
>
Link
</th>
<th className={columnClass}>Accepted By</th>
</tr>
</thead>
<tbody className={'divide-y divide-gray-200 bg-white'}>
{links.map((link) => (
<YourLinkSummaryRow challenge={link} />
))}
</tbody>
</table>
</div>
)
}
function YourLinkSummaryRow(props: { challenge: Challenge }) {
const { challenge } = props
const { acceptances } = challenge
const [open, setOpen] = React.useState(false)
const className = clsx(
'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white'
)
return (
<>
<Modal open={open} setOpen={setOpen} size={'sm'}>
<Col
className={
'items-center justify-center gap-4 rounded-md bg-white p-8 py-8 '
}
>
<span className={'mb-4 text-center text-xl text-indigo-700'}>
Have your friend scan this to accept the challenge!
</span>
<QRCode url={getChallengeUrl(challenge)} />
</Col>
</Modal>
<tr id={challenge.slug} key={challenge.slug} className={className}>
<td className={amountClass}>
<SiteLink href={getChallengeUrl(challenge)}>
{formatMoney(challenge.creatorAmount)}
</SiteLink>
</td>
<td
className={clsx(
columnClass,
'text-center sm:max-w-[200px] sm:text-start'
)}
>
<Row className="items-center gap-2">
<Button
color="gray-white"
size="xs"
onClick={() => {
copyToClipboard(getChallengeUrl(challenge))
toast('Link copied to clipboard!')
}}
>
<ClipboardCopyIcon className={'h-5 w-5 sm:h-4 sm:w-4'} />
</Button>
<Button
color="gray-white"
size="xs"
onClick={() => {
setOpen(true)
}}
>
<QrcodeIcon className="h-5 w-5 sm:h-4 sm:w-4" />
</Button>
<SiteLink
href={getChallengeUrl(challenge)}
className={'mx-1 mb-1 hidden sm:inline-block'}
>
{`...${challenge.contractSlug}/${challenge.slug}`}
</SiteLink>
</Row>
</td>
<td className={columnClass}>
<Row className={'items-center justify-start gap-1'}>
{acceptances.length > 0 ? (
<>
<Avatar
username={acceptances[0].userUsername}
avatarUrl={acceptances[0].userAvatarUrl}
size={'sm'}
/>
<UserLink
name={acceptances[0].userName}
username={acceptances[0].userUsername}
/>
</>
) : (
<span>
No one -
{challenge.expiresTime &&
` (expires ${fromNow(challenge.expiresTime)})`}
</span>
)}
</Row>
</td>
</tr>
</>
)
}
function PublicChallengesTable(props: { links: Challenge[] }) {
const { links } = props
return links.length == 0 ? (
<p>There aren't currently any challenges.</p>
) : (
<div className="overflow-scroll">
<table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th className={amountClass}>Amount</th>
<th className={columnClass}>Creator</th>
<th className={columnClass}>Acceptor</th>
<th className={columnClass}>Market</th>
</tr>
</thead>
<tbody className={'divide-y divide-gray-200 bg-white'}>
{links.map((link) => (
<PublicLinkSummaryRow challenge={link} />
))}
</tbody>
</table>
</div>
)
}
function PublicLinkSummaryRow(props: { challenge: Challenge }) {
const { challenge } = props
const {
acceptances,
creatorUsername,
creatorName,
creatorAvatarUrl,
contractCreatorUsername,
contractQuestion,
contractSlug,
} = challenge
const className = clsx(
'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white'
)
return (
<tr
id={challenge.slug + '-public'}
key={challenge.slug + '-public'}
className={className}
onClick={() => Router.push(getChallengeUrl(challenge))}
>
<td className={amountClass}>
<SiteLink href={getChallengeUrl(challenge)}>
{formatMoney(challenge.creatorAmount)}
</SiteLink>
</td>
<td className={clsx(columnClass)}>
<Row className={'items-center justify-start gap-1'}>
<Avatar
username={creatorUsername}
avatarUrl={creatorAvatarUrl}
size={'sm'}
noLink={true}
/>
<UserLink name={creatorName} username={creatorUsername} />
</Row>
</td>
<td className={clsx(columnClass)}>
<Row className={'items-center justify-start gap-1'}>
{acceptances.length > 0 ? (
<>
<Avatar
username={acceptances[0].userUsername}
avatarUrl={acceptances[0].userAvatarUrl}
size={'sm'}
noLink={true}
/>
<UserLink
name={acceptances[0].userName}
username={acceptances[0].userUsername}
/>
</>
) : (
<span>
No one -
{challenge.expiresTime &&
` (expires ${fromNow(challenge.expiresTime)})`}
</span>
)}
</Row>
</td>
<td className={clsx(columnClass, 'font-bold')}>
<SiteLink
href={contractPathWithoutContract(
contractCreatorUsername,
contractSlug
)}
>
{contractQuestion}
</SiteLink>
</td>
</tr>
)
}

View File

@ -21,8 +21,11 @@ import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useWindowSize } from 'web/hooks/use-window-size'
import { listAllBets } from 'web/lib/firebase/bets'
import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import {
contractPath,
getContractFromSlug,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import Custom404 from '../../404'
export const getStaticProps = fromPropz(getStaticPropz)
@ -76,7 +79,7 @@ export default function ContractEmbedPage(props: {
return <ContractEmbed contract={contract} bets={bets} />
}
function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const { question, outcomeType } = contract

View File

@ -160,7 +160,7 @@ export default function GroupPage(props: {
const privateUser = usePrivateUser(user?.id)
useSaveReferral(user, {
defaultReferrer: creator.username,
defaultReferrerUsername: creator.username,
groupId: group?.id,
})

View File

@ -91,5 +91,5 @@ const useReferral = (user: User | undefined | null, manalink?: Manalink) => {
if (manalink?.fromId) getUser(manalink.fromId).then(setCreator)
}, [manalink])
useSaveReferral(user, { defaultReferrer: creator?.username })
useSaveReferral(user, { defaultReferrerUsername: creator?.username })
}

View File

@ -811,6 +811,7 @@ function getSourceUrl(notification: Notification) {
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
@ -913,6 +914,15 @@ function NotificationTextLabel(props: {
<span>of your limit order was filled</span>
</>
)
} else if (sourceType === 'challenge' && sourceText) {
return (
<>
<span> for </span>
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>
</>
)
}
return (
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
@ -967,6 +977,9 @@ function getReasonForShowingNotification(
case 'bet':
reasonText = 'bet against you'
break
case 'challenge':
reasonText = 'accepted your challenge'
break
default:
reasonText = ''
}