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:
parent
2d3ca47b52
commit
798253f887
|
@ -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
63
common/challenge.ts
Normal 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
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
|
|
164
functions/src/accept-challenge.ts
Normal file
164
functions/src/accept-challenge.ts
Normal 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 }
|
||||
})
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 `What’s 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
|
||||
? What’s 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">
|
||||
|
|
203
og-image/api/_lib/challenge-template.ts
Normal file
203
og-image/api/_lib/challenge-template.ts
Normal 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://manifold.markets/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>`
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
125
web/components/challenges/accept-challenge-button.tsx
Normal file
125
web/components/challenges/accept-challenge-button.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
255
web/components/challenges/create-challenge-button.tsx
Normal file
255
web/components/challenges/create-challenge-button.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
36
web/components/contract/contract-card-preview.tsx
Normal file
36
web/components/contract/contract-card-preview.tsx
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,6 +61,15 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
if (!user) {
|
||||
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' },
|
||||
|
@ -68,6 +78,21 @@ function getMoreNavigation(user?: User | null) {
|
|||
]
|
||||
}
|
||||
|
||||
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' },
|
||||
|
@ -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' },
|
||||
|
|
|
@ -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,6 +43,7 @@ export const PortfolioValueSection = memo(
|
|||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{!disableSelector && (
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
onChange={(e) => {
|
||||
|
@ -50,6 +54,7 @@ export const PortfolioValueSection = memo(
|
|||
<option value="weekly">7 days</option>
|
||||
<option value="daily">24 hours</option>
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={portfolioHistory}
|
||||
|
|
18
web/components/share-market-button.tsx
Normal file
18
web/components/share-market-button.tsx
Normal 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'}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
150
web/lib/firebase/challenges.ts
Normal file
150
web/lib/firebase/challenges.ts
Normal 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
|
||||
}
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
300
web/pages/challenges/index.tsx
Normal file
300
web/pages/challenges/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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 = ''
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user