Merge branch 'main' into comment-rich-text

This commit is contained in:
Sinclair Chen 2022-08-04 15:40:41 -07:00
commit bb6a63a230
57 changed files with 3395 additions and 276 deletions

View File

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

63
common/challenge.ts Normal file
View File

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

View File

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

View File

@ -47,6 +47,7 @@ export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
@ -56,6 +57,7 @@ export type PrivateUser = {
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string

View File

@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import { uniq } from 'lodash'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) =>
export const searchInAny = (query: string, ...fields: string[]) =>
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
export const exhibitExts = [
Blockquote,

View File

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

View File

@ -31,6 +31,7 @@
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"dayjs": "1.11.4",
"cors": "2.8.5",
"express": "4.18.1",
"firebase-admin": "10.0.0",

View File

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

View File

@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
const firestore = admin.firestore()
type user_to_reason_texts = {
@ -32,7 +33,7 @@ export const createNotification = async (
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
relatedUserId?: string
recipients?: string[]
slug?: string
title?: string
}
@ -40,7 +41,7 @@ export const createNotification = async (
const {
contract: sourceContract,
relatedSourceType,
relatedUserId,
recipients,
slug,
title,
} = miscData ?? {}
@ -127,7 +128,7 @@ export const createNotification = async (
})
}
const notifyRepliedUsers = async (
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
@ -144,7 +145,7 @@ export const createNotification = async (
}
}
const notifyFollowedUser = async (
const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
) => {
@ -154,21 +155,24 @@ export const createNotification = async (
}
}
const notifyTaggedUsers = async (
userToReasonTexts: user_to_reason_texts,
sourceText: string
) => {
const taggedUsers = sourceText.match(/@\w+/g)
if (!taggedUsers) return
// await all get tagged users:
const users = await Promise.all(
taggedUsers.map(async (username) => {
return await getUserByUsername(username.slice(1))
})
/** @deprecated parse from rich text instead */
const parseMentions = async (source: string) => {
const mentions = source.match(/@\w+/g)
if (!mentions) return []
return Promise.all(
mentions.map(
async (username) => (await getUserByUsername(username.slice(1)))?.id
)
)
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',
}
})
@ -253,7 +257,7 @@ export const createNotification = async (
})
}
const notifyUserAddedToGroup = async (
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
@ -275,11 +279,14 @@ export const createNotification = async (
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined.
@ -292,13 +299,10 @@ export const createNotification = async (
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText)
notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
@ -307,6 +311,7 @@ export const createNotification = async (
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
@ -478,3 +483,35 @@ export const createReferralNotification = async (
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (
challenger: User,
challengeCreator: User,
challenge: Challenge,
acceptedAmount: number,
contract: Contract
) => {
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: challengeCreator.id,
reason: 'challenge_accepted',
createdTime: Date.now(),
isSeen: false,
sourceId: challenge.slug,
sourceType: 'challenge',
sourceUpdateType: 'updated',
sourceUserName: challenger.name,
sourceUserUsername: challenger.username,
sourceUserAvatarUrl: challenger.avatarUrl,
sourceText: acceptedAmount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -1,5 +1,7 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
@ -24,7 +26,6 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { uniq } from 'lodash'
import {
DEV_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 sendWelcomeEmail(user, privateUser)
await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user

File diff suppressed because one or more lines are too long

View 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"
>
&nbsp;
</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"
>
&nbsp;
</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"
>
&nbsp;
</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!&nbsp;</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&apos;s weekly active
users.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</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>

View File

@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
)
}
// TODO: use manalinks to give out M$500
export const sendOneWeekBonusEmail = async (
user: User,
privateUser: PrivateUser
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
await sendTemplateEmail(
privateUser.email,
'Manifold one week anniversary gift',
'Manifold Markets one week anniversary gift',
'one-week',
{
name: firstName,
unsubscribeLink,
manalink: '', // TODO
manalink: 'https://manifold.markets/link/lj4JbBvE',
},
{
from: 'David from Manifold <david@manifold.markets>',

View File

@ -27,6 +27,25 @@ export * from './on-delete-group'
export * from './score-contracts'
// 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 { transact } from './transact'
import { changeuserinfo } from './change-user-info'
@ -45,6 +64,7 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -68,6 +88,7 @@ const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
export {
healthFunction as health,
@ -89,4 +110,5 @@ export {
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
}

View 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
}
}

View File

@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions
? 'answer'
: undefined
const relatedUserId = comment.replyToCommentId
const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
const recipients = repliedUserId ? [repliedUserId] : []
await createNotification(
comment.id,
@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions
commentCreator,
eventId,
comment.text,
{ contract, relatedSourceType, relatedUserId }
{ contract, relatedSourceType, recipients }
)
const recipientUserIds = uniq([

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { richTextToString } from '../../common/util/parse'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
export const onCreateContract = functions.firestore
@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore
const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) throw new Error('Could not find contract creator')
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
await createNotification(
contract.id,
'contract',
'created',
contractCreator,
eventId,
richTextToString(contract.description as JSONContent),
{ contract }
richTextToString(desc),
{ contract, recipients: mentioned }
)
})

View File

@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
for (const memberId of group.memberIds) {
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
relatedUserId: memberId,
slug: group.slug,
title: group.name,
}
)
}
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
recipients: group.memberIds,
slug: group.slug,
title: group.name,
}
)
})

View File

@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore
followingUser,
eventId,
'',
{ relatedUserId: follow.userId }
{ recipients: [follow.userId] }
)
})

View File

@ -26,9 +26,10 @@ export const sendTemplateEmail = (
subject: string,
templateId: 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>',
to,
subject,
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
'h:X-Mailgun-Variables': JSON.stringify(templateData),
}
const mg = initMailgun()
return mg.messages().send(data, (error) => {
if (error) console.log('Error sending email', error)
else console.log('Sent template email', templateId, to, subject)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -213,8 +213,8 @@ export function GroupChatInBubble(props: {
return (
<Col
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',
shouldShowChat ? 'z-10 bg-white p-2' : ''
'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 ? 'p-2m z-10 h-screen bg-white' : ''
)}
>
{shouldShowChat && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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))
}

View File

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

View File

@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -5144,6 +5144,11 @@ dayjs@1.10.7:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
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:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"