Merge branch 'main' into comment-rich-text
This commit is contained in:
commit
bb6a63a230
|
@ -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'
|
||||||
|
|
|
@ -47,6 +47,7 @@ export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
// for sus users, i.e. multiple sign ups for same person
|
// for sus users, i.e. multiple sign ups for same person
|
||||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||||
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
@ -56,6 +57,7 @@ export type PrivateUser = {
|
||||||
unsubscribedFromCommentEmails?: boolean
|
unsubscribedFromCommentEmails?: boolean
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
unsubscribedFromAnswerEmails?: boolean
|
||||||
unsubscribedFromGenericEmails?: boolean
|
unsubscribedFromGenericEmails?: boolean
|
||||||
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
import { Mention } from '@tiptap/extension-mention'
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
|
@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) =>
|
||||||
export const searchInAny = (query: string, ...fields: string[]) =>
|
export const searchInAny = (query: string, ...fields: string[]) =>
|
||||||
fields.some((field) => checkAgainstQuery(query, field))
|
fields.some((field) => checkAgainstQuery(query, field))
|
||||||
|
|
||||||
|
/** @return user ids of all \@mentions */
|
||||||
|
export function parseMentions(data: JSONContent): string[] {
|
||||||
|
const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs
|
||||||
|
if (data.type === 'mention' && data.attrs) {
|
||||||
|
mentions.push(data.attrs.id as string)
|
||||||
|
}
|
||||||
|
return uniq(mentions)
|
||||||
|
}
|
||||||
|
|
||||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||||
export const exhibitExts = [
|
export const exhibitExts = [
|
||||||
Blockquote,
|
Blockquote,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -31,6 +31,7 @@
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
|
"dayjs": "1.11.4",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "4.18.1",
|
"express": "4.18.1",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
|
|
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 = {
|
||||||
|
@ -32,7 +33,7 @@ export const createNotification = async (
|
||||||
miscData?: {
|
miscData?: {
|
||||||
contract?: Contract
|
contract?: Contract
|
||||||
relatedSourceType?: notification_source_types
|
relatedSourceType?: notification_source_types
|
||||||
relatedUserId?: string
|
recipients?: string[]
|
||||||
slug?: string
|
slug?: string
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
@ -40,7 +41,7 @@ export const createNotification = async (
|
||||||
const {
|
const {
|
||||||
contract: sourceContract,
|
contract: sourceContract,
|
||||||
relatedSourceType,
|
relatedSourceType,
|
||||||
relatedUserId,
|
recipients,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
} = miscData ?? {}
|
} = miscData ?? {}
|
||||||
|
@ -127,7 +128,7 @@ export const createNotification = async (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyRepliedUsers = async (
|
const notifyRepliedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
relatedUserId: string,
|
relatedUserId: string,
|
||||||
relatedSourceType: notification_source_types
|
relatedSourceType: notification_source_types
|
||||||
|
@ -144,7 +145,7 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyFollowedUser = async (
|
const notifyFollowedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
followedUserId: string
|
followedUserId: string
|
||||||
) => {
|
) => {
|
||||||
|
@ -154,21 +155,24 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyTaggedUsers = async (
|
/** @deprecated parse from rich text instead */
|
||||||
userToReasonTexts: user_to_reason_texts,
|
const parseMentions = async (source: string) => {
|
||||||
sourceText: string
|
const mentions = source.match(/@\w+/g)
|
||||||
) => {
|
if (!mentions) return []
|
||||||
const taggedUsers = sourceText.match(/@\w+/g)
|
return Promise.all(
|
||||||
if (!taggedUsers) return
|
mentions.map(
|
||||||
// await all get tagged users:
|
async (username) => (await getUserByUsername(username.slice(1)))?.id
|
||||||
const users = await Promise.all(
|
)
|
||||||
taggedUsers.map(async (username) => {
|
|
||||||
return await getUserByUsername(username.slice(1))
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
users.forEach((taggedUser) => {
|
}
|
||||||
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
|
|
||||||
userToReasonTexts[taggedUser.id] = {
|
const notifyTaggedUsers = (
|
||||||
|
userToReasonTexts: user_to_reason_texts,
|
||||||
|
userIds: (string | undefined)[]
|
||||||
|
) => {
|
||||||
|
userIds.forEach((id) => {
|
||||||
|
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||||
|
userToReasonTexts[id] = {
|
||||||
reason: 'tagged_user',
|
reason: 'tagged_user',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -253,7 +257,7 @@ export const createNotification = async (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyUserAddedToGroup = async (
|
const notifyUserAddedToGroup = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
relatedUserId: string
|
relatedUserId: string
|
||||||
) => {
|
) => {
|
||||||
|
@ -275,11 +279,14 @@ export const createNotification = async (
|
||||||
const getUsersToNotify = async () => {
|
const getUsersToNotify = async () => {
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
if (sourceType === 'follow' && relatedUserId) {
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||||
} else if (sourceType === 'group' && relatedUserId) {
|
} else if (
|
||||||
if (sourceUpdateType === 'created')
|
sourceType === 'group' &&
|
||||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
sourceUpdateType === 'created' &&
|
||||||
|
recipients
|
||||||
|
) {
|
||||||
|
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The following functions need sourceContract to be defined.
|
// The following functions need sourceContract to be defined.
|
||||||
|
@ -292,13 +299,10 @@ export const createNotification = async (
|
||||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||||
) {
|
) {
|
||||||
if (sourceType === 'comment') {
|
if (sourceType === 'comment') {
|
||||||
if (relatedUserId && relatedSourceType)
|
if (recipients?.[0] && relatedSourceType)
|
||||||
await notifyRepliedUsers(
|
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||||
userToReasonTexts,
|
if (sourceText)
|
||||||
relatedUserId,
|
notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
|
||||||
relatedSourceType
|
|
||||||
)
|
|
||||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
|
||||||
}
|
}
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||||
|
@ -307,6 +311,7 @@ export const createNotification = async (
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
|
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||||
force: true,
|
force: true,
|
||||||
|
@ -478,3 +483,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))
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MANIFOLD_AVATAR_URL,
|
MANIFOLD_AVATAR_URL,
|
||||||
MANIFOLD_USERNAME,
|
MANIFOLD_USERNAME,
|
||||||
|
@ -24,7 +26,6 @@ import {
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||||
import { uniq } from 'lodash'
|
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
@ -93,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
||||||
await sendWelcomeEmail(user, privateUser)
|
|
||||||
await addUserToDefaultGroups(user)
|
await addUserToDefaultGroups(user)
|
||||||
|
await sendWelcomeEmail(user, privateUser)
|
||||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
File diff suppressed because one or more lines are too long
738
functions/src/email-templates/creating-market.html
Normal file
738
functions/src/email-templates/creating-market.html
Normal file
|
@ -0,0 +1,738 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html
|
||||||
|
xmlns="http://www.w3.org/1999/xhtml"
|
||||||
|
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<title>(no subject)</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG />
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
|
<div style="background-color: #f4f4f4">
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
align="center"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 0px;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="vertical-align: top"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0px 25px 0px 25px;
|
||||||
|
padding-top: 0px;
|
||||||
|
padding-right: 25px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width: 550px">
|
||||||
|
<a
|
||||||
|
href="https://manifold.markets/home"
|
||||||
|
target="_blank"
|
||||||
|
><img
|
||||||
|
alt=""
|
||||||
|
height="auto"
|
||||||
|
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
|
||||||
|
style="
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
"
|
||||||
|
width="550"
|
||||||
|
/></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
align="center"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="vertical-align: top"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0px 25px 20px 25px;
|
||||||
|
padding-top: 0px;
|
||||||
|
padding-right: 25px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
padding-left: 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
color: #000000;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="
|
||||||
|
line-height: 23px;
|
||||||
|
margin: 10px 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>On Manifold Markets, several important factors
|
||||||
|
go into making a good question. These lead to
|
||||||
|
more people betting on them and allowing a more
|
||||||
|
accurate prediction to be formed!</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>Manifold also gives its creators 10 Mana for
|
||||||
|
each unique trader that bets on your
|
||||||
|
market!</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #292fd7;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
"
|
||||||
|
><b>What makes a good question?</b></span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><b>Clear resolution criteria. </b>This is
|
||||||
|
needed so users know how you are going to
|
||||||
|
decide on what the correct answer is.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><b>Clear resolution date</b>. This is
|
||||||
|
sometimes slightly different from the closing
|
||||||
|
date. We recommend leaving the market open up
|
||||||
|
until you resolve it, but if it is different
|
||||||
|
make sure you say what day you intend to
|
||||||
|
resolve it in the description!</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><b>Detailed description. </b>Use the rich
|
||||||
|
text editor to create an easy to read
|
||||||
|
description. Include any context or background
|
||||||
|
information that could be useful to people who
|
||||||
|
are interested in learning more that are
|
||||||
|
uneducated on the subject.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><b>Add it to a group. </b>Groups are the
|
||||||
|
primary way users filter for relevant markets.
|
||||||
|
Also, consider making your own groups and
|
||||||
|
inviting friends/interested communities to
|
||||||
|
them from other sites!</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><b>Bonus: </b>Add a comment on your
|
||||||
|
prediction and explain (with links and
|
||||||
|
sources) supporting it.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #292fd7;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
"
|
||||||
|
><b
|
||||||
|
>Examples of markets you should
|
||||||
|
emulate! </b
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<a
|
||||||
|
class="link-build-content"
|
||||||
|
style="color: inherit; text-decoration: none"
|
||||||
|
target="_blank"
|
||||||
|
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
color: #55575d;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><u>This complex market</u></span
|
||||||
|
></a
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
about the project I am working on.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li style="line-height: 23px">
|
||||||
|
<a
|
||||||
|
class="link-build-content"
|
||||||
|
style="color: inherit; text-decoration: none"
|
||||||
|
target="_blank"
|
||||||
|
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
color: #55575d;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><u>This simple market</u></span
|
||||||
|
></a
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
about Manifold's weekly active
|
||||||
|
users.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #000000;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>Why not </span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="link-build-content"
|
||||||
|
style="color: inherit; text-decoration: none"
|
||||||
|
target="_blank"
|
||||||
|
href="https://manifold.markets/create"
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
color: #55575d;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
><u>create a market</u></span
|
||||||
|
></a
|
||||||
|
><span
|
||||||
|
style="
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
while it is still fresh on your mind?
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #000000;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>Thanks for reading!</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="
|
||||||
|
line-height: 23px;
|
||||||
|
margin: 10px 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #000000;
|
||||||
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"
|
||||||
|
>David from Manifold</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table
|
||||||
|
align="center"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table
|
||||||
|
align="center"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 20px 0px;
|
||||||
|
text-align: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0">
|
||||||
|
<table
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="margin: 10px 0">
|
||||||
|
This e-mail has been sent to {{name}},
|
||||||
|
<a
|
||||||
|
href="{{unsubscribeLink}}"
|
||||||
|
style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
>click here to unsubscribe</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"
|
||||||
|
></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use manalinks to give out M$500
|
|
||||||
export const sendOneWeekBonusEmail = async (
|
export const sendOneWeekBonusEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
|
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
|
||||||
|
|
||||||
await sendTemplateEmail(
|
await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeLink,
|
||||||
manalink: '', // TODO
|
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
|
|
@ -27,6 +27,25 @@ export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
|
export * from './health'
|
||||||
|
export * from './transact'
|
||||||
|
export * from './change-user-info'
|
||||||
|
export * from './create-user'
|
||||||
|
export * from './create-answer'
|
||||||
|
export * from './place-bet'
|
||||||
|
export * from './cancel-bet'
|
||||||
|
export * from './sell-bet'
|
||||||
|
export * from './sell-shares'
|
||||||
|
export * from './claim-manalink'
|
||||||
|
export * from './create-contract'
|
||||||
|
export * from './add-liquidity'
|
||||||
|
export * from './withdraw-liquidity'
|
||||||
|
export * from './create-group'
|
||||||
|
export * from './resolve-market'
|
||||||
|
export * from './unsubscribe'
|
||||||
|
export * from './stripe'
|
||||||
|
export * from './mana-bonus-email'
|
||||||
|
|
||||||
import { health } from './health'
|
import { health } from './health'
|
||||||
import { transact } from './transact'
|
import { transact } from './transact'
|
||||||
import { changeuserinfo } from './change-user-info'
|
import { changeuserinfo } from './change-user-info'
|
||||||
|
@ -45,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)
|
||||||
|
@ -68,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,
|
||||||
|
@ -89,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,
|
||||||
}
|
}
|
||||||
|
|
42
functions/src/mana-bonus-email.ts
Normal file
42
functions/src/mana-bonus-email.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import * as dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import { getPrivateUser } from './utils'
|
||||||
|
import { sendOneWeekBonusEmail } from './emails'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
|
||||||
|
export const manabonusemail = functions
|
||||||
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
.pubsub.schedule('0 9 * * 1-7')
|
||||||
|
.onRun(async () => {
|
||||||
|
await sendOneWeekEmails()
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function sendOneWeekEmails() {
|
||||||
|
const oneWeekAgo = dayjs().subtract(1, 'week').valueOf()
|
||||||
|
const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf()
|
||||||
|
|
||||||
|
const userDocs = await firestore
|
||||||
|
.collection('users')
|
||||||
|
.where('createdTime', '<=', oneWeekAgo)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
for (const user of userDocs.docs.map((d) => d.data() as User)) {
|
||||||
|
if (user.createdTime < twoWeekAgo) continue
|
||||||
|
|
||||||
|
const privateUser = await getPrivateUser(user.id)
|
||||||
|
if (!privateUser || privateUser.manaBonusEmailSent) continue
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection('private-users')
|
||||||
|
.doc(user.id)
|
||||||
|
.update({ manaBonusEmailSent: true })
|
||||||
|
|
||||||
|
console.log('sending m$ bonus email to', user.username)
|
||||||
|
await sendOneWeekBonusEmail(user, privateUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
|
@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions
|
||||||
? 'answer'
|
? 'answer'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const relatedUserId = comment.replyToCommentId
|
const repliedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
const recipients = repliedUserId ? [repliedUserId] : []
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
comment.id,
|
comment.id,
|
||||||
|
@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
comment.text,
|
comment.text,
|
||||||
{ contract, relatedSourceType, relatedUserId }
|
{ contract, relatedSourceType, recipients }
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
export const onCreateContract = functions.firestore
|
export const onCreateContract = functions.firestore
|
||||||
|
@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore
|
||||||
const contractCreator = await getUser(contract.creatorId)
|
const contractCreator = await getUser(contract.creatorId)
|
||||||
if (!contractCreator) throw new Error('Could not find contract creator')
|
if (!contractCreator) throw new Error('Could not find contract creator')
|
||||||
|
|
||||||
|
const desc = contract.description as JSONContent
|
||||||
|
const mentioned = parseMentions(desc)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'created',
|
'created',
|
||||||
contractCreator,
|
contractCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(contract.description as JSONContent),
|
richTextToString(desc),
|
||||||
{ contract }
|
{ contract, recipients: mentioned }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore
|
||||||
const groupCreator = await getUser(group.creatorId)
|
const groupCreator = await getUser(group.creatorId)
|
||||||
if (!groupCreator) throw new Error('Could not find group creator')
|
if (!groupCreator) throw new Error('Could not find group creator')
|
||||||
// create notifications for all members of the group
|
// create notifications for all members of the group
|
||||||
for (const memberId of group.memberIds) {
|
await createNotification(
|
||||||
await createNotification(
|
group.id,
|
||||||
group.id,
|
'group',
|
||||||
'group',
|
'created',
|
||||||
'created',
|
groupCreator,
|
||||||
groupCreator,
|
eventId,
|
||||||
eventId,
|
group.about,
|
||||||
group.about,
|
{
|
||||||
{
|
recipients: group.memberIds,
|
||||||
relatedUserId: memberId,
|
slug: group.slug,
|
||||||
slug: group.slug,
|
title: group.name,
|
||||||
title: group.name,
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore
|
||||||
followingUser,
|
followingUser,
|
||||||
eventId,
|
eventId,
|
||||||
'',
|
'',
|
||||||
{ relatedUserId: follow.userId }
|
{ recipients: [follow.userId] }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,10 @@ export const sendTemplateEmail = (
|
||||||
subject: string,
|
subject: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
templateData: Record<string, string>,
|
templateData: Record<string, string>,
|
||||||
options?: { from: string }
|
options?: Partial<mailgun.messages.SendTemplateData>
|
||||||
) => {
|
) => {
|
||||||
const data = {
|
const data: mailgun.messages.SendTemplateData = {
|
||||||
|
...options,
|
||||||
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
|
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
|
||||||
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
||||||
}
|
}
|
||||||
const mg = initMailgun()
|
const mg = initMailgun()
|
||||||
|
|
||||||
return mg.messages().send(data, (error) => {
|
return mg.messages().send(data, (error) => {
|
||||||
if (error) console.log('Error sending email', error)
|
if (error) console.log('Error sending email', error)
|
||||||
else console.log('Sent template email', templateId, to, subject)
|
else console.log('Sent template email', templateId, to, subject)
|
||||||
|
|
|
@ -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)
|
||||||
For more info, see Contributing.md
|
- note2: (Or `cd .. && vercel --prod`, I think)
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
For more info, see Contributing.md
|
||||||
(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:
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
# [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} />
|
||||||
|
|
|
@ -213,8 +213,8 @@ export function GroupChatInBubble(props: {
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed right-0 bottom-[0px] h-screen w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
|
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
|
||||||
shouldShowChat ? 'z-10 bg-white p-2' : ''
|
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shouldShowChat && (
|
{shouldShowChat && (
|
||||||
|
|
|
@ -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,26 +61,50 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return [
|
if (CHALLENGES_ENABLED)
|
||||||
{ name: 'Charity', href: '/charity' },
|
return [
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
{ name: 'Challenges', href: '/challenges' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||||
]
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||||
|
]
|
||||||
|
else
|
||||||
|
return [
|
||||||
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
if (CHALLENGES_ENABLED)
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
return [
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Challenges', href: '/challenges' },
|
||||||
{ name: 'Send M$', href: '/links' },
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'Send M$', href: '/links' },
|
||||||
{
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
name: 'Sign out',
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
href: '#',
|
{
|
||||||
onClick: logout,
|
name: 'Sign out',
|
||||||
},
|
href: '#',
|
||||||
]
|
onClick: logout,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
else
|
||||||
|
return [
|
||||||
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
|
{
|
||||||
|
name: 'Sign out',
|
||||||
|
href: '#',
|
||||||
|
onClick: logout,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
|
@ -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,16 +43,18 @@ export const PortfolioValueSection = memo(
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
<select
|
{!disableSelector && (
|
||||||
className="select select-bordered self-start"
|
<select
|
||||||
onChange={(e) => {
|
className="select select-bordered self-start"
|
||||||
setPortfolioPeriod(e.target.value as Period)
|
onChange={(e) => {
|
||||||
}}
|
setPortfolioPeriod(e.target.value as Period)
|
||||||
>
|
}}
|
||||||
<option value="allTime">{allTimeLabel}</option>
|
>
|
||||||
<option value="weekly">7 days</option>
|
<option value="allTime">{allTimeLabel}</option>
|
||||||
<option value="daily">24 hours</option>
|
<option value="weekly">7 days</option>
|
||||||
</select>
|
<option value="daily">24 hours</option>
|
||||||
|
</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(() => {
|
||||||
|
if (fromId) 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
23
web/pages/api/v0/market/[id]/lite.ts
Normal file
23
web/pages/api/v0/market/[id]/lite.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { getContractFromId } from 'web/lib/firebase/contracts'
|
||||||
|
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||||
|
import { ApiError, toLiteMarket, LiteMarket } from '../../_types'
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<LiteMarket | ApiError>
|
||||||
|
) {
|
||||||
|
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||||
|
const { id } = req.query
|
||||||
|
const contractId = id as string
|
||||||
|
|
||||||
|
const contract = await getContractFromId(contractId)
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
res.status(404).json({ error: 'Contract not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'max-age=0')
|
||||||
|
return res.status(200).json(toLiteMarket(contract))
|
||||||
|
}
|
|
@ -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={
|
||||||
|
' 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>
|
||||||
|
)
|
||||||
|
}
|
304
web/pages/challenges/index.tsx
Normal file
304
web/pages/challenges/index.tsx
Normal file
|
@ -0,0 +1,304 @@
|
||||||
|
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 challenges = useAcceptedChallenges()
|
||||||
|
const userChallenges = useUserChallenges(user?.id)
|
||||||
|
.concat(
|
||||||
|
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.createdTime - a.createdTime)
|
||||||
|
|
||||||
|
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 = ''
|
||||||
}
|
}
|
||||||
|
|
|
@ -5144,6 +5144,11 @@ dayjs@1.10.7:
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
||||||
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
||||||
|
|
||||||
|
dayjs@1.11.4:
|
||||||
|
version "1.11.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e"
|
||||||
|
integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g==
|
||||||
|
|
||||||
debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9:
|
debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user