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
|
isAnte?: boolean
|
||||||
isLiquidityProvision?: boolean
|
isLiquidityProvision?: boolean
|
||||||
isRedemption?: boolean
|
isRedemption?: boolean
|
||||||
|
challengeSlug?: string
|
||||||
} & Partial<LimitProps>
|
} & Partial<LimitProps>
|
||||||
|
|
||||||
export type NumericBet = Bet & {
|
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'
|
| 'group'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'bonus'
|
| 'bonus'
|
||||||
|
| 'challenge'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -64,3 +65,4 @@ export type notification_reason_types =
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
|
| 'challenge_accepted'
|
||||||
|
|
|
@ -39,6 +39,17 @@ service cloud.firestore {
|
||||||
allow read;
|
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} {
|
match /users/{userId}/follows/{followUserId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow write: if request.auth.uid == userId;
|
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 { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
|
import { Challenge } from '../../common/challenge'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
|
@ -478,3 +479,35 @@ export const createReferralNotification = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
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 { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
import { acceptchallenge } from './accept-challenge'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -87,6 +88,7 @@ const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -108,4 +110,5 @@ export {
|
||||||
stripeWebhookFunction as stripewebhook,
|
stripeWebhookFunction as stripewebhook,
|
||||||
createCheckoutSessionFunction as createcheckoutsession,
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
getCurrentUserFunction as getcurrentuser,
|
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
|
# Quickstart
|
||||||
|
|
||||||
1. To get started: `yarn install`
|
1. To test locally: `yarn start`
|
||||||
2. To test locally: `yarn start`
|
|
||||||
The local image preview is broken for some reason; but the service works.
|
The local image preview is broken for some reason; but the service works.
|
||||||
E.g. try `http://localhost:3000/manifold.png`
|
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
|
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)
|
(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)
|
# [Open Graph Image as a Service](https://og-image.vercel.app)
|
||||||
|
|
||||||
<a href="https://twitter.com/vercel">
|
<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,
|
creatorName,
|
||||||
creatorUsername,
|
creatorUsername,
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
|
|
||||||
|
// Challenge attributes:
|
||||||
|
challengerAmount,
|
||||||
|
challengerOutcome,
|
||||||
|
creatorAmount,
|
||||||
|
creatorOutcome,
|
||||||
|
acceptedName,
|
||||||
|
acceptedAvatarUrl,
|
||||||
} = query || {}
|
} = query || {}
|
||||||
|
|
||||||
if (Array.isArray(fontSize)) {
|
if (Array.isArray(fontSize)) {
|
||||||
|
@ -67,6 +75,12 @@ export function parseRequest(req: IncomingMessage) {
|
||||||
creatorName: getString(creatorName) || 'Manifold Markets',
|
creatorName: getString(creatorName) || 'Manifold Markets',
|
||||||
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
|
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
|
||||||
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
|
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)
|
parsedRequest.images = getDefaultImages(parsedRequest.images)
|
||||||
return parsedRequest
|
return parsedRequest
|
||||||
|
|
|
@ -126,7 +126,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mantic logo -->
|
<!-- Manifold logo -->
|
||||||
<div class="absolute right-24 top-8">
|
<div class="absolute right-24 top-8">
|
||||||
<a class="flex flex-row gap-3" href="/"
|
<a class="flex flex-row gap-3" href="/"
|
||||||
><img
|
><img
|
||||||
|
|
|
@ -1,21 +1,28 @@
|
||||||
export type FileType = "png" | "jpeg";
|
export type FileType = 'png' | 'jpeg'
|
||||||
export type Theme = "light" | "dark";
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
export interface ParsedRequest {
|
export interface ParsedRequest {
|
||||||
fileType: FileType;
|
fileType: FileType
|
||||||
text: string;
|
text: string
|
||||||
theme: Theme;
|
theme: Theme
|
||||||
md: boolean;
|
md: boolean
|
||||||
fontSize: string;
|
fontSize: string
|
||||||
images: string[];
|
images: string[]
|
||||||
widths: string[];
|
widths: string[]
|
||||||
heights: string[];
|
heights: string[]
|
||||||
|
|
||||||
// Attributes for Manifold card:
|
// Attributes for Manifold card:
|
||||||
question: string;
|
question: string
|
||||||
probability: string;
|
probability: string
|
||||||
metadata: string;
|
metadata: string
|
||||||
creatorName: string;
|
creatorName: string
|
||||||
creatorUsername: string;
|
creatorUsername: string
|
||||||
creatorAvatarUrl: 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 { IncomingMessage, ServerResponse } from 'http'
|
||||||
import { parseRequest } from "./_lib/parser";
|
import { parseRequest } from './_lib/parser'
|
||||||
import { getScreenshot } from "./_lib/chromium";
|
import { getScreenshot } from './_lib/chromium'
|
||||||
import { getHtml } from "./_lib/template";
|
import { getHtml } from './_lib/template'
|
||||||
|
import { getChallengeHtml } from './_lib/challenge-template'
|
||||||
|
|
||||||
const isDev = !process.env.AWS_REGION;
|
const isDev = !process.env.AWS_REGION
|
||||||
const isHtmlDebug = process.env.OG_HTML_DEBUG === "1";
|
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1'
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse
|
res: ServerResponse
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const parsedReq = parseRequest(req);
|
const parsedReq = parseRequest(req)
|
||||||
const html = getHtml(parsedReq);
|
let html = getHtml(parsedReq)
|
||||||
|
if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq)
|
||||||
if (isHtmlDebug) {
|
if (isHtmlDebug) {
|
||||||
res.setHeader("Content-Type", "text/html");
|
res.setHeader('Content-Type', 'text/html')
|
||||||
res.end(html);
|
res.end(html)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const { fileType } = parsedReq;
|
const { fileType } = parsedReq
|
||||||
const file = await getScreenshot(html, fileType, isDev);
|
const file = await getScreenshot(html, fileType, isDev)
|
||||||
res.statusCode = 200;
|
res.statusCode = 200
|
||||||
res.setHeader("Content-Type", `image/${fileType}`);
|
res.setHeader('Content-Type', `image/${fileType}`)
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Cache-Control",
|
'Cache-Control',
|
||||||
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
|
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
|
||||||
);
|
)
|
||||||
res.end(file);
|
res.end(file)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.statusCode = 500;
|
res.statusCode = 500
|
||||||
res.setHeader("Content-Type", "text/html");
|
res.setHeader('Content-Type', 'text/html')
|
||||||
res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
|
res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>')
|
||||||
console.error(e);
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
|
import { Challenge } from 'common/challenge'
|
||||||
|
|
||||||
export type OgCardProps = {
|
export type OgCardProps = {
|
||||||
question: string
|
question: string
|
||||||
|
@ -10,7 +11,16 @@ export type OgCardProps = {
|
||||||
creatorAvatarUrl?: string
|
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 =
|
const probabilityParam =
|
||||||
props.probability === undefined
|
props.probability === undefined
|
||||||
? ''
|
? ''
|
||||||
|
@ -20,6 +30,12 @@ function buildCardUrl(props: OgCardProps) {
|
||||||
? ''
|
? ''
|
||||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
: `&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
|
// URL encode each of the props, then add them as query params
|
||||||
return (
|
return (
|
||||||
`https://manifold-og-image.vercel.app/m.png` +
|
`https://manifold-og-image.vercel.app/m.png` +
|
||||||
|
@ -28,7 +44,8 @@ function buildCardUrl(props: OgCardProps) {
|
||||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||||
creatorAvatarUrlParam +
|
creatorAvatarUrlParam +
|
||||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||||
|
challengeUrlParams
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,8 +55,9 @@ export function SEO(props: {
|
||||||
url?: string
|
url?: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
ogCardProps?: OgCardProps
|
ogCardProps?: OgCardProps
|
||||||
|
challenge?: Challenge
|
||||||
}) {
|
}) {
|
||||||
const { title, description, url, children, ogCardProps } = props
|
const { title, description, url, children, ogCardProps, challenge } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -71,13 +89,13 @@ export function SEO(props: {
|
||||||
<>
|
<>
|
||||||
<meta
|
<meta
|
||||||
property="og:image"
|
property="og:image"
|
||||||
content={buildCardUrl(ogCardProps)}
|
content={buildCardUrl(ogCardProps, challenge)}
|
||||||
key="image1"
|
key="image1"
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" key="card" />
|
<meta name="twitter:card" content="summary_large_image" key="card" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:image"
|
name="twitter:image"
|
||||||
content={buildCardUrl(ogCardProps)}
|
content={buildCardUrl(ogCardProps, challenge)}
|
||||||
key="image2"
|
key="image2"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -16,8 +16,7 @@ import {
|
||||||
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import { Bet, LimitBet } from 'common/bet'
|
import { Bet, LimitBet } from 'common/bet'
|
||||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||||
import { sellShares } from 'web/lib/firebase/api'
|
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
import { InfoTooltip } from './info-tooltip'
|
||||||
import {
|
import {
|
||||||
|
@ -351,7 +350,7 @@ function BuyPanel(props: {
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'btn flex-1',
|
'btn mb-2 flex-1',
|
||||||
betDisabled
|
betDisabled
|
||||||
? 'btn-disabled'
|
? 'btn-disabled'
|
||||||
: outcome === 'YES'
|
: outcome === 'YES'
|
||||||
|
|
|
@ -5,8 +5,16 @@ export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
|
color?:
|
||||||
|
| 'green'
|
||||||
|
| 'red'
|
||||||
|
| 'blue'
|
||||||
|
| 'indigo'
|
||||||
|
| 'yellow'
|
||||||
|
| 'gray'
|
||||||
|
| 'gradient'
|
||||||
|
| 'gray-white'
|
||||||
type?: 'button' | 'reset' | 'submit'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -26,6 +34,7 @@ export function Button(props: {
|
||||||
md: 'px-4 py-2 text-sm',
|
md: 'px-4 py-2 text-sm',
|
||||||
lg: 'px-4 py-2 text-base',
|
lg: 'px-4 py-2 text-base',
|
||||||
xl: 'px-6 py-3 text-base',
|
xl: 'px-6 py-3 text-base',
|
||||||
|
'2xl': 'px-6 py-3 text-xl',
|
||||||
}[size]
|
}[size]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -39,8 +48,9 @@ export function Button(props: {
|
||||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
color === 'gray' &&
|
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||||
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
|
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' &&
|
color === 'gray-white' &&
|
||||||
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
|
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
|
||||||
className
|
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 { Col } from '../layout/col'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { ContractProbGraph } from './contract-prob-graph'
|
import { ContractProbGraph } from './contract-prob-graph'
|
||||||
|
@ -8,8 +8,8 @@ import { Linkify } from '../linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FreeResponseResolutionOrChance,
|
|
||||||
BinaryResolutionOrChance,
|
BinaryResolutionOrChance,
|
||||||
|
FreeResponseResolutionOrChance,
|
||||||
NumericResolutionOrExpectation,
|
NumericResolutionOrExpectation,
|
||||||
PseudoNumericResolutionOrExpectation,
|
PseudoNumericResolutionOrExpectation,
|
||||||
} from './contract-card'
|
} from './contract-card'
|
||||||
|
@ -19,8 +19,13 @@ import { AnswersGraph } from '../answers/answers-graph'
|
||||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||||
import { ContractDescription } from './contract-description'
|
import { ContractDescription } from './contract-description'
|
||||||
import { ContractDetails } from './contract-details'
|
import { ContractDetails } from './contract-details'
|
||||||
import { ShareMarket } from '../share-market'
|
|
||||||
import { NumericGraph } from './numeric-graph'
|
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: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -32,8 +37,10 @@ export const ContractOverview = (props: {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isCreator = user?.id === creatorId
|
const isCreator = user?.id === creatorId
|
||||||
|
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
const showChallenge = user && isBinary && !resolution && CHALLENGES_ENABLED
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('mb-6', className)}>
|
<Col className={clsx('mb-6', className)}>
|
||||||
|
@ -116,13 +123,47 @@ export const ContractOverview = (props: {
|
||||||
<AnswersGraph contract={contract} bets={bets} />
|
<AnswersGraph contract={contract} bets={bets} />
|
||||||
)}
|
)}
|
||||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
||||||
{(contract.description || isCreator) && <Spacer h={6} />}
|
{/* {(contract.description || isCreator) && <Spacer h={6} />} */}
|
||||||
{isCreator && <ShareMarket className="px-2" contract={contract} />}
|
|
||||||
<ContractDescription
|
<ContractDescription
|
||||||
className="px-2"
|
className="px-2"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isCreator={isCreator}
|
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>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import React, { Fragment } from 'react'
|
||||||
import { LinkIcon } from '@heroicons/react/outline'
|
import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
@ -14,6 +13,8 @@ export function CopyLinkButton(props: {
|
||||||
tracking?: string
|
tracking?: string
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
toastClassName?: string
|
toastClassName?: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
label?: string
|
||||||
}) {
|
}) {
|
||||||
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,10 @@ export function ContractActivity(props: {
|
||||||
|
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
const comments = props.comments
|
const comments = props.comments
|
||||||
const updatedBets = useBets(contract.id)
|
const updatedBets = useBets(contract.id, {
|
||||||
|
filterChallenges: false,
|
||||||
|
filterRedemptions: true,
|
||||||
|
})
|
||||||
const bets = (updatedBets ?? props.bets).filter(
|
const bets = (updatedBets ?? props.bets).filter(
|
||||||
(bet) => !bet.isRedemption && bet.amount !== 0
|
(bet) => !bet.isRedemption && bet.amount !== 0
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid'
|
||||||
import { formatMoney, formatPercent } from 'common/util/format'
|
import { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
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 { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
||||||
import { JoinSpans } from 'web/components/join-spans'
|
import { JoinSpans } from 'web/components/join-spans'
|
||||||
import { UserLink } from '../user-page'
|
import { UserLink } from '../user-page'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
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: {
|
export function FeedBet(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -79,7 +82,15 @@ export function BetStatusText(props: {
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
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 bought = amount >= 0 ? 'bought' : 'sold'
|
||||||
const outOfTotalAmount =
|
const outOfTotalAmount =
|
||||||
|
@ -133,6 +144,14 @@ export function BetStatusText(props: {
|
||||||
{fromProb === toProb
|
{fromProb === toProb
|
||||||
? `at ${fromProb}`
|
? `at ${fromProb}`
|
||||||
: `from ${fromProb} to ${toProb}`}
|
: `from ${fromProb} to ${toProb}`}
|
||||||
|
{challengeSlug && (
|
||||||
|
<SiteLink
|
||||||
|
href={challenge ? getChallengeUrl(challenge) : ''}
|
||||||
|
className={'mx-1'}
|
||||||
|
>
|
||||||
|
[challenge]
|
||||||
|
</SiteLink>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { Spacer } from '../layout/spacer'
|
||||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
// log out, and then reload the page, in case SSR wants to boot them out
|
// 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 (!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 [
|
return [
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
{ 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 [
|
return [
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
@ -119,6 +144,14 @@ function getMoreMobileNav() {
|
||||||
return [
|
return [
|
||||||
...(IS_PRIVATE_MANIFOLD
|
...(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: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
|
|
@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||||
export const PortfolioValueSection = memo(
|
export const PortfolioValueSection = memo(
|
||||||
function PortfolioValueSection(props: {
|
function PortfolioValueSection(props: {
|
||||||
portfolioHistory: PortfolioMetrics[]
|
portfolioHistory: PortfolioMetrics[]
|
||||||
|
disableSelector?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { portfolioHistory } = props
|
const { portfolioHistory, disableSelector } = props
|
||||||
const lastPortfolioMetrics = last(portfolioHistory)
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||||
|
|
||||||
|
@ -30,7 +31,9 @@ export const PortfolioValueSection = memo(
|
||||||
<div>
|
<div>
|
||||||
<Row className="gap-8">
|
<Row className="gap-8">
|
||||||
<div className="mb-4 w-full">
|
<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-sm text-gray-500">Portfolio value</div>
|
||||||
<div className="text-lg">
|
<div className="text-lg">
|
||||||
{formatMoney(
|
{formatMoney(
|
||||||
|
@ -40,6 +43,7 @@ export const PortfolioValueSection = memo(
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
|
{!disableSelector && (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered self-start"
|
className="select select-bordered self-start"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
@ -50,6 +54,7 @@ export const PortfolioValueSection = memo(
|
||||||
<option value="weekly">7 days</option>
|
<option value="weekly">7 days</option>
|
||||||
<option value="daily">24 hours</option>
|
<option value="daily">24 hours</option>
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<PortfolioValueGraph
|
<PortfolioValueGraph
|
||||||
portfolioHistory={portfolioHistory}
|
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 { useUser } from 'web/hooks/use-user'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
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()
|
const user = useUser()
|
||||||
|
|
||||||
return user === null ? (
|
return user === null ? (
|
||||||
<button
|
<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"
|
|
||||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||||
|
className={className}
|
||||||
|
size="lg"
|
||||||
|
color="gradient"
|
||||||
>
|
>
|
||||||
Sign up to bet!
|
{label ?? 'Sign up to bet!'}
|
||||||
</button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,12 +9,26 @@ import {
|
||||||
} from 'web/lib/firebase/bets'
|
} from 'web/lib/firebase/bets'
|
||||||
import { LimitBet } from 'common/bet'
|
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>()
|
const [bets, setBets] = useState<Bet[] | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId) return listenForBets(contractId, setBets)
|
if (contractId)
|
||||||
}, [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
|
return bets
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users'
|
||||||
export const useSaveReferral = (
|
export const useSaveReferral = (
|
||||||
user?: User | null,
|
user?: User | null,
|
||||||
options?: {
|
options?: {
|
||||||
defaultReferrer?: string
|
defaultReferrerUsername?: string
|
||||||
contractId?: string
|
contractId?: string
|
||||||
groupId?: string
|
groupId?: string
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ export const useSaveReferral = (
|
||||||
referrer?: string
|
referrer?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const referrerOrDefault = referrer || options?.defaultReferrer
|
const referrerOrDefault = referrer || options?.defaultReferrerUsername
|
||||||
|
|
||||||
if (!user && router.isReady && referrerOrDefault) {
|
if (!user && router.isReady && referrerOrDefault) {
|
||||||
writeReferralInfo(referrerOrDefault, {
|
writeReferralInfo(referrerOrDefault, {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { doc, DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData, where } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
|
|
@ -81,6 +81,10 @@ export function createGroup(params: any) {
|
||||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function acceptChallenge(params: any) {
|
||||||
|
return call(getFunctionUrl('acceptchallenge'), 'POST', params)
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentUser(params: any) {
|
export function getCurrentUser(params: any) {
|
||||||
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
|
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}`
|
return `/${contract.creatorUsername}/${contract.slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function contractPathWithoutContract(
|
||||||
|
creatorUsername: string,
|
||||||
|
slug: string
|
||||||
|
) {
|
||||||
|
return `/${creatorUsername}/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
export function homeContractPath(contract: Contract) {
|
export function homeContractPath(contract: Contract) {
|
||||||
return `/home?c=${contract.slug}`
|
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 { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||||
|
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||||
import { BetPanel } from 'web/components/bet-panel'
|
import { BetPanel } from 'web/components/bet-panel'
|
||||||
import { Col } from 'web/components/layout/col'
|
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 { ResolutionPanel } from 'web/components/resolution-panel'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
getContractFromSlug,
|
getContractFromSlug,
|
||||||
tradingAllowed,
|
tradingAllowed,
|
||||||
getBinaryProbPercent,
|
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
|
@ -21,26 +21,29 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments'
|
||||||
import Custom404 from '../404'
|
import Custom404 from '../404'
|
||||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
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 { ContractTabs } from 'web/components/contract/contract-tabs'
|
||||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import Confetti from 'react-confetti'
|
import Confetti from 'react-confetti'
|
||||||
import { NumericBetPanel } from '../../components/numeric-bet-panel'
|
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
|
||||||
import { NumericResolutionPanel } from '../../components/numeric-resolution-panel'
|
import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
|
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
|
||||||
import { useBets } from 'web/hooks/use-bets'
|
import { useBets } from 'web/hooks/use-bets'
|
||||||
import { CPMMBinaryContract } from 'common/contract'
|
import { CPMMBinaryContract } from 'common/contract'
|
||||||
import { AlertBox } from 'web/components/alert-box'
|
import { AlertBox } from 'web/components/alert-box'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
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 { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
import { richTextToString } from 'common/util/parse'
|
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import {
|
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
||||||
ContractLeaderboard,
|
import { User } from 'common/user'
|
||||||
ContractTopTrades,
|
import { listUsers } from 'web/lib/firebase/users'
|
||||||
} from 'web/components/contract/contract-leaderboard'
|
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 const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -153,7 +156,7 @@ export function ContractPageContent(
|
||||||
const ogCardProps = getOpenGraphProps(contract)
|
const ogCardProps = getOpenGraphProps(contract)
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrer: contract.creatorUsername,
|
defaultReferrerUsername: contract.creatorUsername,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -208,7 +211,10 @@ export function ContractPageContent(
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContractOverview contract={contract} bets={bets} />
|
<ContractOverview
|
||||||
|
contract={contract}
|
||||||
|
bets={bets.filter((b) => !b.challengeSlug)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
<AlertBox
|
<AlertBox
|
||||||
|
@ -258,34 +264,125 @@ export function ContractPageContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOpenGraphProps = (contract: Contract) => {
|
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const {
|
const { contract, bets } = props
|
||||||
resolution,
|
const [users, setUsers] = useState<User[]>()
|
||||||
question,
|
|
||||||
creatorName,
|
|
||||||
creatorUsername,
|
|
||||||
outcomeType,
|
|
||||||
creatorAvatarUrl,
|
|
||||||
description: desc,
|
|
||||||
} = contract
|
|
||||||
const probPercent =
|
|
||||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
|
||||||
|
|
||||||
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
|
const userProfits = mapValues(betsByUser, (bets) =>
|
||||||
? `Resolved ${resolution}. ${stringDesc}`
|
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||||
: probPercent
|
)
|
||||||
? `${probPercent} chance. ${stringDesc}`
|
// Find the 5 users with the most profits
|
||||||
: stringDesc
|
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 {
|
useEffect(() => {
|
||||||
question,
|
if (top5Ids.length > 0) {
|
||||||
probability: probPercent,
|
listUsers(top5Ids).then((users) => {
|
||||||
metadata: contractTextDetails(contract),
|
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||||
creatorName,
|
setUsers(sortedUsers)
|
||||||
creatorUsername,
|
})
|
||||||
creatorAvatarUrl,
|
}
|
||||||
description,
|
}, [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 { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { listAllBets } from 'web/lib/firebase/bets'
|
import { listAllBets } from 'web/lib/firebase/bets'
|
||||||
import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts'
|
import {
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
contractPath,
|
||||||
|
getContractFromSlug,
|
||||||
|
tradingAllowed,
|
||||||
|
} from 'web/lib/firebase/contracts'
|
||||||
import Custom404 from '../../404'
|
import Custom404 from '../../404'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
|
@ -76,7 +79,7 @@ export default function ContractEmbedPage(props: {
|
||||||
return <ContractEmbed contract={contract} bets={bets} />
|
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 { contract, bets } = props
|
||||||
const { question, outcomeType } = contract
|
const { question, outcomeType } = contract
|
||||||
|
|
||||||
|
|
|
@ -160,7 +160,7 @@ export default function GroupPage(props: {
|
||||||
const privateUser = usePrivateUser(user?.id)
|
const privateUser = usePrivateUser(user?.id)
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrer: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
groupId: group?.id,
|
groupId: group?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -91,5 +91,5 @@ const useReferral = (user: User | undefined | null, manalink?: Manalink) => {
|
||||||
if (manalink?.fromId) getUser(manalink.fromId).then(setCreator)
|
if (manalink?.fromId) getUser(manalink.fromId).then(setCreator)
|
||||||
}, [manalink])
|
}, [manalink])
|
||||||
|
|
||||||
useSaveReferral(user, { defaultReferrer: creator?.username })
|
useSaveReferral(user, { defaultReferrerUsername: creator?.username })
|
||||||
}
|
}
|
||||||
|
|
|
@ -811,6 +811,7 @@ function getSourceUrl(notification: Notification) {
|
||||||
if (sourceType === 'tip' && sourceContractSlug)
|
if (sourceType === 'tip' && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||||
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
|
@ -913,6 +914,15 @@ function NotificationTextLabel(props: {
|
||||||
<span>of your limit order was filled</span>
|
<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 (
|
return (
|
||||||
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
|
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
|
||||||
|
@ -967,6 +977,9 @@ function getReasonForShowingNotification(
|
||||||
case 'bet':
|
case 'bet':
|
||||||
reasonText = 'bet against you'
|
reasonText = 'bet against you'
|
||||||
break
|
break
|
||||||
|
case 'challenge':
|
||||||
|
reasonText = 'accepted your challenge'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
reasonText = ''
|
reasonText = ''
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user