Merge branch 'main' into add-funds-modal

This commit is contained in:
Sinclair Chen 2022-10-03 09:59:52 -07:00
commit 92ebf35cdc
113 changed files with 3539 additions and 1557 deletions

View File

@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userName: string userName: string
userUsername: string userUsername: string
userAvatarUrl?: string userAvatarUrl?: string
bountiesAwarded?: number
} & T } & T
export type OnContract = { export type OnContract = {

View File

@ -62,6 +62,9 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[] likedByUserIds?: string[]
likedByUserCount?: number likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number
unlistedById?: string
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT =
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -41,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
} }
type FirebaseConfig = { type FirebaseConfig = {

View File

@ -23,6 +23,7 @@ export type Group = {
score: number score: number
}[] }[]
} }
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75

View File

@ -5,4 +5,4 @@ export type Like = {
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 5 export const LIKE_TIP_AMOUNT = 10

View File

@ -96,6 +96,7 @@ type notification_descriptions = {
[key in notification_preference]: { [key in notification_preference]: {
simple: string simple: string
detailed: string detailed: string
necessary?: boolean
} }
} }
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -116,8 +117,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching", detailed: "Only answers by market creator on markets you're watching",
}, },
betting_streaks: { betting_streaks: {
simple: 'For predictions made over consecutive days', simple: `For prediction streaks`,
detailed: 'Bonuses for predictions made over consecutive days', detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
}, },
comments_by_followed_users_on_watched_markets: { comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow', simple: 'Only comments by users you follow',
@ -159,8 +160,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch', detailed: 'Large changes in probability on markets that you watch',
}, },
profit_loss_updates: { profit_loss_updates: {
simple: 'Weekly profit and loss updates', simple: 'Weekly portfolio updates',
detailed: 'Weekly profit and loss updates', detailed: 'Weekly portfolio updates',
}, },
referral_bonuses: { referral_bonuses: {
simple: 'For referring new users', simple: 'For referring new users',
@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets', detailed: 'Bonuses for unique predictors on your markets',
}, },
your_contract_closed: { your_contract_closed: {
simple: 'Your market has closed and you need to resolve it', simple: 'Your market has closed and you need to resolve it (necessary)',
detailed: 'Your market has closed and you need to resolve it', detailed: 'Your market has closed and you need to resolve it (necessary)',
necessary: true,
}, },
all_comments_on_watched_markets: { all_comments_on_watched_markets: {
simple: 'All new comments', simple: 'All new comments',
@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`, simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`,
}, },
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:
'Opt out of all notifications excluding your own market closure notifications',
},
} }
export type BettingStreakData = { export type BettingStreakData = {

View File

@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus | UniqueBettorBonus
| BettingStreakBonus | BettingStreakBonus
| CancelUniqueBettorBonus | CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS' | 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS' | 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
} }
} }
type CommentBountyDeposit = {
fromType: 'USER'
toType: 'BANK'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
}
}
type CommentBountyWithdrawal = {
fromType: 'BANK'
toType: 'USER'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
commentId: string
}
}
type CommentBountyRefund = {
fromType: 'BANK'
toType: 'USER'
category: 'REFUND_COMMENT_BOUNTY'
data: {
contractId: string
}
}
export type DonationTxn = Txn & Donation export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink export type ManalinkTxn = Txn & Manalink
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal

View File

@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[] profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[] onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[] thank_you_for_purchases: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
} }
export const getDefaultNotificationPreferences = ( export const getDefaultNotificationPreferences = (
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[] return filterDefined([browser, email]) as notification_destination_types[]
} }
return { const defaults: notification_preferences = {
// Watched Markets // Watched Markets
all_comments_on_watched_markets: constructPref(true, false), all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false),
@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false), probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false), thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false), onboarding_flow: constructPref(false, false),
} as notification_preferences
opt_out_all: [],
}
return defaults
} }
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types // Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = (
? notificationSettings[subscriptionType] ? notificationSettings[subscriptionType]
: [] : []
} }
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe') const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { return {
sendToEmail: destinations.includes('email'), sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser'), sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
} }

View File

@ -33,6 +33,8 @@ export type User = {
allTime: number allTime: number
} }
fractionResolvedCorrectly: number
nextLoanCached: number nextLoanCached: number
followerCountCached: number followerCountCached: number

View File

@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
}) })
export function formatMoney(amount: number) { export function formatMoney(amount: number) {
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case const newAmount =
// handle -0 case
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
Math.floor(amount + 0.00000000001 * Math.sign(amount))
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }

View File

@ -102,7 +102,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question']) .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;
allow update: if isAdmin(); allow update: if isAdmin();
match /comments/{commentId} { match /comments/{commentId} {
@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {

View File

@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
totalContracts: 0, totalContracts: 0,
totalMembers: memberIds.length, totalMembers: memberIds.length,
postIds: [], postIds: [],
pinnedItems: [],
} }
await groupRef.create(group) await groupRef.create(group)

View File

@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async (
) )
) )
} }
export const createBountyNotification = async (
fromUser: User,
toUserId: string,
amount: number,
idempotencyKey: string,
contract: Contract,
commentId?: string
) => {
const privateUser = await getPrivateUser(toUserId)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'tip_received'
)
if (!sendToBrowser) return
const slug = commentId
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: commentId ? commentId : contract.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to comment creator
}

View File

@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
} }
await firestore.collection('users').doc(auth.uid).create(user) await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -483,11 +483,7 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
">our Discord</a>! Or, ">our Discord</a>!
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -320,7 +320,7 @@
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;" style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y"> data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> <span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
And here's some of the biggest changes in your portfolio: And here's some recent changes in your investments:
</span> </span>
</p> </p>
</div> </div>

View File

@ -643,13 +643,13 @@ export const sendWeeklyPortfolioUpdateEmail = async (
templateData[`question${i + 1}Title`] = investment.questionTitle templateData[`question${i + 1}Title`] = investment.questionTitle
templateData[`question${i + 1}Url`] = investment.questionUrl templateData[`question${i + 1}Url`] = investment.questionUrl
templateData[`question${i + 1}Prob`] = investment.questionProb templateData[`question${i + 1}Prob`] = investment.questionProb
templateData[`question${i + 1}Change`] = formatMoney(investment.difference) templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
}) })
await sendTemplateEmail( await sendTemplateEmail(
// privateUser.email, privateUser.email,
'iansphilips@gmail.com', // 'iansphilips@gmail.com',
`Here's your weekly portfolio update!`, `Here's your weekly portfolio update!`,
investments.length === 0 investments.length === 0
? 'portfolio-update-no-movers' ? 'portfolio-update-no-movers'

View File

@ -52,6 +52,7 @@ export * from './unsubscribe'
export * from './stripe' export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market' export * from './close-market'
export * from './update-comment-bounty'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -65,6 +66,7 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity) const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
@ -127,4 +131,6 @@ export {
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
} }

View File

@ -7,38 +7,47 @@ export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
.onUpdate(async (change, context) => { .onUpdate(async (change, context) => {
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if ( if (
previousValue.closeTime !== contract.closeTime || !previousContract.isResolved &&
previousValue.question !== contract.question contract.isResolved &&
(openCommentBounties ?? 0) > 0
) { ) {
let sourceText = '' // No need to notify users of resolution, that's handled in resolve-market
if ( return
previousValue.closeTime !== contract.closeTime && }
contract.closeTime if (
) { previousContract.closeTime !== closeTime ||
sourceText = contract.closeTime.toString() previousContract.question !== question
} else if (previousValue.question !== contract.question) { ) {
sourceText = contract.question await handleUpdatedCloseTime(previousContract, contract, eventId)
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
contract
)
} }
}) })
async function handleUpdatedCloseTime(
previousContract: Contract,
contract: Contract,
eventId: string
) {
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
let sourceText = ''
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
sourceText = contract.closeTime.toString()
} else if (previousContract.question !== contract.question) {
sourceText = contract.question
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
contract
)
}

View File

@ -0,0 +1,27 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
opt_out_all: [],
},
})
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -0,0 +1,63 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'dev.manifold.markets'
// Dev API key for Cause Exploration Prizes (@CEP)
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
const GROUP_SLUG = 'cart-contest'
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
return await resp.json()
}
async function getMarketsByGroupId(id: string) {
// API structure: /v0/group/by-id/[id]/markets
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
/* Example curl request:
# Resolve a binary market
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES"}'
*/
async function resolveMarketById(
id: string,
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
) {
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcome,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug(GROUP_SLUG)
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
console.log(
'Number of resolved markets',
markets.filter((m: any) => m.isResolved).length
)
// Resolve each market to NO
for (const market of markets) {
if (!market.isResolved) {
console.log(`Resolving market ${market.url} to NO`)
const resp = await resolveMarketById(market.id, 'NO')
}
}
}
main()

View File

@ -42,6 +42,7 @@ const createGroup = async (
totalContracts: contracts.length, totalContracts: contracts.length,
totalMembers: 1, totalMembers: 1,
postIds: [], postIds: [],
pinnedItems: [],
} }
await groupRef.create(group) await groupRef.create(group)
// create a GroupMemberDoc for the creator // create a GroupMemberDoc for the creator

View File

@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function' import { testscheduledfunction } from './test-scheduled-function'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity) addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)

View File

@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences' import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = { export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 }, opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.') res.status(400).send('Invalid subscription type parameter.')
return return
} }
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id) const user = await getPrivateUser(id)
@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = {
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
notificationPreferences: { notificationPreferences: {
...user.notificationPreferences, ...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter( [notificationSubscriptionType]: wantsToOptOutAll
(destination) => destination !== 'email' ? previousDestinations.push('email')
), : previousDestinations.filter(
(destination) => destination !== 'email'
),
}, },
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
res.send( const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
` if (wantsToOptOutAll) {
<!DOCTYPE html> res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"> xmlns:o="urn:schemas-microsoft-com:office:office">
@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = {
</a> </a>
</td> </td>
</tr> </tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr> <tr>
<td align="left" <td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = {
data-testid="4XoHRGw1Y"> data-testid="4XoHRGw1Y">
<span <span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to: ${email} has opted out of receiving unnecessary email notifications
</span> </span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div> </div>
</td> </td>
@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = {
</div> </div>
</div> </div>
</body> </body>
</html>`
)
} else {
res.send(
`
<!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>Manifold Markets 7th Day Anniversary Gift!</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]-->
<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 style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to:
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html> </html>
` `
) )
}
}, },
} }

View File

@ -0,0 +1,162 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
import {
CommentBountyDepositTxn,
CommentBountyWithdrawalTxn,
} from '../../common/txn'
import { runTxn } from './transact'
import { Comment } from '../../common/comment'
import { createBountyNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
const awardBodySchema = z.object({
contractId: z.string(),
commentId: z.string(),
amount: z.number().gt(0),
})
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.balance < amount)
throw new APIError(400, 'Insufficient user balance')
const newCommentBountyTxn = {
fromId: user.id,
fromType: 'USER',
toId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
toType: 'BANK',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
},
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
} as CommentBountyDepositTxn
const result = await runTxn(transaction, newCommentBountyTxn)
transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
})
)
return result
})
})
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
const res = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.id !== contract.creatorId)
throw new APIError(
400,
'Only contract creator can award comment bounties'
)
const commentDoc = firestore.doc(
`contracts/${contractId}/comments/${commentId}`
)
const commentSnap = await transaction.get(commentDoc)
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
const comment = commentSnap.data() as Comment
const amountAvailable = contract.openCommentBounties ?? 0
if (amountAvailable < amount)
throw new APIError(400, 'Insufficient open bounty balance')
const newCommentBountyTxn = {
fromId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
fromType: 'BANK',
toId: comment.userId,
toType: 'USER',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
commentId,
},
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
} as CommentBountyWithdrawalTxn
const result = await runTxn(transaction, newCommentBountyTxn)
await transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: amountAvailable - amount,
})
)
await transaction.update(
commentDoc,
removeUndefinedProps({
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
})
)
return { ...result, comment, contract, user }
})
if (res.txn?.id) {
const { comment, contract, user } = res
await createBountyNotification(
user,
comment.userId,
amount,
res.txn.id,
contract,
comment.id
)
}
return res
})
const firestore = admin.firestore()

View File

@ -135,6 +135,28 @@ export async function updateMetricsCore() {
lastPortfolio.investmentValue !== newPortfolio.investmentValue lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const contractRatios = userContracts
.map((contract) => {
if (
!contract.flaggedByUsernames ||
contract.flaggedByUsernames?.length === 0
) {
return 0
}
const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
return contractRatio
})
.filter((ratio) => ratio > 0)
const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
)
let newFractionResolvedCorrectly = 0
if (userContracts.length > 0) {
newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length
}
return { return {
user, user,
@ -142,6 +164,7 @@ export async function updateMetricsCore() {
newPortfolio, newPortfolio,
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly,
} }
}) })
@ -163,6 +186,7 @@ export async function updateMetricsCore() {
newPortfolio, newPortfolio,
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly,
}) => { }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
@ -172,6 +196,7 @@ export async function updateMetricsCore() {
creatorVolumeCached: newCreatorVolume, creatorVolumeCached: newCreatorVolume,
profitCached: newProfit, profitCached: newProfit,
nextLoanCached, nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
}, },
}, },
@ -243,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => {
} }
type GroupContractDoc = { contractId: string; createdTime: number } type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1

View File

@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
const firestore = admin.firestore() const firestore = admin.firestore()
const numberOfDays = 90 const numberOfDays = 180
const getBetsQuery = (startTime: number, endTime: number) => const getBetsQuery = (startTime: number, endTime: number) =>
firestore firestore

View File

@ -48,7 +48,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
return isProd() return isProd()
? user.notificationPreferences.profit_loss_updates.includes('email') && ? user.notificationPreferences.profit_loss_updates.includes('email') &&
!user.weeklyPortfolioUpdateEmailSent !user.weeklyPortfolioUpdateEmailSent
: true : user.notificationPreferences.profit_loss_updates.includes('email')
}) })
// Send emails in batches // Send emails in batches
.slice(0, 200) .slice(0, 200)
@ -117,7 +117,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return // Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
const userBets = usersBets[privateUser.id] as Bet[] const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) => const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id) userBets.some((bet) => bet.contractId === contract.id)
@ -195,15 +196,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
contract, contract,
betsInLastWeek betsInLastWeek
).profit ).profit
const marketChange =
currentMarketProbability - marketProbabilityAWeekAgo
const profit = const profit =
betsMadeInLastWeekProfit + betsMadeInLastWeekProfit +
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
return { return {
currentValue: currentBetsMadeAWeekAgoValue, currentValue: currentBetsMadeAWeekAgoValue,
pastValue: betsMadeAWeekAgoValue, pastValue: betsMadeAWeekAgoValue,
difference: profit, profit,
contractSlug: contract.slug, contractSlug: contract.slug,
marketProbAWeekAgo: marketProbabilityAWeekAgo, marketProbAWeekAgo: marketProbabilityAWeekAgo,
questionTitle: contract.question, questionTitle: contract.question,
@ -211,17 +210,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
questionProb: cpmmContract.resolution questionProb: cpmmContract.resolution
? cpmmContract.resolution ? cpmmContract.resolution
: Math.round(cpmmContract.prob * 100) + '%', : Math.round(cpmmContract.prob * 100) + '%',
questionChange: profitStyle: `color: ${
(marketChange > 0 ? '+' : '') +
Math.round(marketChange * 100) +
'%',
questionChangeStyle: `color: ${
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
};`, };`,
} as PerContractInvestmentsData } as PerContractInvestmentsData
}) })
), ),
(differences) => Math.abs(differences.difference) (differences) => Math.abs(differences.profit)
).reverse() ).reverse()
log( log(
@ -233,12 +228,10 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
const [winningInvestments, losingInvestments] = partition( const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter( investmentValueDifferences.filter(
(diff) => (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
diff.pastValue > 0.01 &&
Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1%
), ),
(investmentsData: PerContractInvestmentsData) => { (investmentsData: PerContractInvestmentsData) => {
return investmentsData.difference > 0 return investmentsData.profit > 0
} }
) )
// pick 3 winning investments and 3 losing investments // pick 3 winning investments and 3 losing investments
@ -251,7 +244,9 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
worstInvestments.length === 0 && worstInvestments.length === 0 &&
usersToContractsCreated[privateUser.id].length === 0 usersToContractsCreated[privateUser.id].length === 0
) { ) {
log('No bets in last week, no market movers, no markets created') log(
'No bets in last week, no market movers, no markets created. Not sending an email.'
)
await firestore.collection('private-users').doc(privateUser.id).update({ await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true, weeklyPortfolioUpdateEmailSent: true,
}) })
@ -268,7 +263,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
}) })
log('Sent weekly portfolio update email to', privateUser.email) log('Sent weekly portfolio update email to', privateUser.email)
count++ count++
log('sent out emails to user count:', count) log('sent out emails to users:', count)
}) })
) )
} }
@ -277,11 +272,10 @@ export type PerContractInvestmentsData = {
questionTitle: string questionTitle: string
questionUrl: string questionUrl: string
questionProb: string questionProb: string
questionChange: string profitStyle: string
questionChangeStyle: string
currentValue: number currentValue: number
pastValue: number pastValue: number
difference: number profit: number
} }
export type OverallPerformanceData = { export type OverallPerformanceData = {

View File

@ -4,7 +4,6 @@ import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Row } from './layout/row' import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal' import { AddFundsModal } from './add-funds-modal'
@ -36,23 +35,20 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false) const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
return ( return (
<> <>
<Col className={className}> <Col className={className}>
<label className="font-sm md:font-lg"> <label className="font-sm md:font-lg relative">
<span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<input <input
className={clsx( className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error', error && 'input-error',
isMobile ? 'w-24' : '', 'w-24 md:w-auto',
inputClassName inputClassName
)} )}
ref={inputRef} ref={inputRef}
@ -61,7 +57,6 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}

View File

@ -1,139 +0,0 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Col } from '../layout/col'
export function DailyCountChart(props: {
startDate: number
dailyCounts: number[]
small?: boolean
}) {
const { dailyCounts, startDate, small } = props
const { width } = useWindowSize()
const dates = dailyCounts.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyCounts).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
const data = [{ id: 'Count', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/>
</div>
)
}
export function DailyPercentChart(props: {
startDate: number
dailyPercent: number[]
small?: boolean
excludeFirstDays?: number
}) {
const { dailyPercent, startDate, small, excludeFirstDays } = props
const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyPercent)
.map(([date, percent]) => ({
x: date,
y: percent,
}))
.slice(excludeFirstDays ?? 0)
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisLeft={{
format: formatPercent,
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} isPercent />
}}
/>
</div>
)
}
function Tooltip(props: { point: Point; isPercent?: boolean }) {
const { point, isPercent } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong>{' '}
{isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>
)
}

View File

@ -184,16 +184,14 @@ export function AnswerBetPanel(props: {
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<WarningConfirmationButton <WarningConfirmationButton
size="xl"
marketType="freeResponse" marketType="freeResponse"
amount={betAmount} amount={betAmount}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled}
openModalButtonClass={clsx( color={'indigo'}
'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary'
)}
/> />
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(false) setIsSubmitting(false)
} }
const resolutionButtonClass =
resolveOption === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: resolveOption === 'CHOOSE' && answers.length
? 'btn-primary'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Row className="justify-between"> <Row className="justify-between">
@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: {
Clear Clear
</button> </button>
)} )}
<ResolveConfirmationButton <ResolveConfirmationButton
color={
resolveOption === 'CANCEL'
? 'yellow'
: resolveOption === 'CHOOSE' && answers.length
? 'green'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'blue'
: 'indigo'
}
disabled={
!resolveOption ||
(resolveOption === 'CHOOSE' && !answers.length) ||
(resolveOption === 'CHOOSE_MULTIPLE' &&
(!(answers.length > 1) ||
!answers.every((answer) => chosenAnswers[answer] > 0)))
}
onResolve={onResolve} onResolve={onResolve}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={resolutionButtonClass}
submitButtonClass={resolutionButtonClass}
/> />
</Row> </Row>
</Col> </Col>

View File

@ -0,0 +1,46 @@
import clsx from 'clsx'
import { ContractComment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
export function AwardBountyButton(prop: {
comment: ContractComment
contract: Contract
}) {
const { comment, contract } = prop
const me = useUser()
const submit = () => {
const data = {
amount: COMMENT_BOUNTY_AMOUNT,
commentId: comment.id,
contractId: contract.id,
}
awardCommentBounty(data)
.then((_) => {
console.log('success')
track('award comment bounty', data)
})
.catch((reason) => console.log('Server error:', reason))
track('award comment bounty', data)
}
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div />
return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
<TextButton className={'font-bold'} onClick={submit}>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton>
</Row>
)
}

View File

@ -17,6 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -85,7 +86,12 @@ export function BinaryMobileBetting(props: { contract: BinaryContract }) {
if (user) { if (user) {
return <SignedInBinaryMobileBetting contract={contract} user={user} /> return <SignedInBinaryMobileBetting contract={contract} user={user} />
} else { } else {
return <BetSignUpPrompt className="w-full" /> return (
<Col className="w-full">
<BetSignUpPrompt className="w-full" />
<PlayMoneyDisclaimer />
</Col>
)
} }
} }

View File

@ -47,7 +47,6 @@ import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { useWindowSize } from 'web/hooks/use-window-size'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -179,12 +178,7 @@ export function BuyPanel(props: {
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const windowSize = useWindowSize() const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const initialOutcome =
windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(
initialOutcome
)
const [betAmount, setBetAmount] = useState<number | undefined>(10) const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -395,22 +389,16 @@ export function BuyPanel(props: {
<WarningConfirmationButton <WarningConfirmationButton
marketType="binary" marketType="binary"
amount={betAmount} amount={betAmount}
outcome={outcome}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={clsx( disabled={!!betDisabled || outcome === undefined}
'btn mb-2 flex-1', size="xl"
betDisabled || outcome === undefined color={outcome === 'NO' ? 'red' : 'green'}
? 'btn-disabled bg-greyscale-2'
: outcome === 'NO'
? 'border-none bg-red-400 hover:bg-red-500'
: 'border-none bg-teal-500 hover:bg-teal-600'
)}
/> />
)} )}
<button <button
className="text-greyscale-6 mx-auto select-none text-sm underline xl:hidden" className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden"
onClick={() => setSeeLimit(true)} onClick={() => setSeeLimit(true)}
> >
Advanced Advanced

View File

@ -2,7 +2,6 @@ import Link from 'next/link'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
@ -46,6 +45,11 @@ import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts' import { useUserBetContracts } from 'web/hooks/use-contracts'
import { BetsSummary } from './bet-summary' import { BetsSummary } from './bet-summary'
import { ProfitBadge } from './profit-badge' import { ProfitBadge } from './profit-badge'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = usePersistentState<BetSort>('newest', {
const [filter, setFilter] = useState<BetFilter>('all') key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
})
const [filter, setFilter] = usePersistentState<BetFilter>('all', {
key: 'bets-list-filter',
store: storageStore(safeLocalStorage()),
})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const start = page * CONTRACTS_PER_PAGE const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE
@ -599,8 +609,8 @@ function SellButton(props: {
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell', label: 'Sell',
disabled: isSubmitting,
}} }}
submitBtn={{ className: 'btn-primary', label: 'Sell' }} submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => { onSubmit={async () => {

View File

@ -46,20 +46,26 @@ export function Button(props: {
<button <button
type={type} type={type}
className={clsx( className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50', 'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses, sizeClasses,
color === 'green' && 'btn-primary text-white', color === 'green' &&
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', 'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'red' &&
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', 'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'yellow' &&
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', 'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' && color === 'gradient' &&
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', 'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' && color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none', 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -1,12 +1,12 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sortBy } from 'lodash' import { last, sortBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getProbability, getInitialProbability } from 'common/calculate' import { getProbability, getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract' import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { import {
TooltipProps, TooltipProps,
MARGIN_X, MARGIN_X,
@ -17,7 +17,6 @@ import {
formatPct, formatPct,
} from '../helpers' } from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -25,19 +24,19 @@ const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: b.probAfter, y: b.probAfter,
datum: b, obj: b,
})) }))
} }
const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2 text-sm"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<strong>{formatPct(y)}</strong> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span>{formatDateInRange(x, start, end)}</span> <span className="text-greyscale-6">{formatPct(data.y)}</span>
</Row> </Row>
) )
} }
@ -45,49 +44,43 @@ const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => {
export const BinaryContractChart = (props: { export const BinaryContractChart = (props: {
contract: BinaryContract contract: BinaryContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets } = props const { contract, bets, width, height, onMouseOver } = props
const [startDate, endDate] = getDateRange(contract) const [start, end] = getDateRange(contract)
const startP = getInitialProbability(contract) const startP = getInitialProbability(contract)
const endP = getProbability(contract) const endP = getProbability(contract)
const betPoints = useMemo(() => getBetPoints(bets), [bets]) const betPoints = useMemo(() => getBetPoints(bets), [bets])
const data = useMemo( const data = useMemo(() => {
() => [ return [
{ x: startDate, y: startP }, { x: new Date(start), y: startP },
...betPoints, ...betPoints,
{ x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, { x: new Date(end ?? Date.now() + DAY_MS), y: endP },
], ]
[startDate, startP, endDate, endP, betPoints] }, [start, startP, end, endP, betPoints])
)
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
endDate, end,
last(betPoints)?.x, last(betPoints)?.x?.getTime(),
new Date(Date.now()) Date.now()
) )
const visibleRange = [startDate, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 250 : 350)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <SingleValueHistoryChart
{width > 0 && ( w={width}
<SingleValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} color="#11b981"
data={data} curve={curveStepAfter}
color="#11b981" onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip} Tooltip={BinaryChartTooltip}
pct pct
/> />
)}
</div>
) )
} }

View File

@ -1,15 +1,14 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash' import { last, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { import {
Legend,
TooltipProps, TooltipProps,
MARGIN_X, MARGIN_X,
MARGIN_Y, MARGIN_Y,
@ -19,7 +18,6 @@ import {
formatDateInRange, formatDateInRange,
} from '../helpers' } from '../helpers'
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -115,18 +113,43 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
points.push({ points.push({
x: new Date(bet.createdTime), x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
datum: bet, obj: bet,
}) })
} }
return points return points
} }
type LegendItem = { color: string; label: string; value?: string }
const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
<ol className={className}>
{items.map((item) => (
<li key={item.label} className="flex flex-row justify-between gap-4">
<Row className="items-center gap-2 overflow-hidden">
<span
className="h-4 w-4 shrink-0"
style={{ backgroundColor: item.color }}
></span>
<span className="text-semibold overflow-hidden text-ellipsis">
{item.label}
</span>
</Row>
<span className="text-greyscale-6">{item.value}</span>
</li>
))}
</ol>
)
}
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: MultiPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets } = props const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const answers = useMemo( const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length), () => getTrackedAnswers(contract, CATEGORY_COLORS.length),
@ -135,10 +158,10 @@ export const ChoiceContractChart = (props: {
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo( const data = useMemo(
() => [ () => [
{ x: start, y: answers.map((_) => 0) }, { x: new Date(start), y: answers.map((_) => 0) },
...betPoints, ...betPoints,
{ {
x: end ?? new Date(Date.now() + DAY_MS), x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)), y: answers.map((a) => getOutcomeProbability(contract, a.id)),
}, },
], ],
@ -146,24 +169,20 @@ export const ChoiceContractChart = (props: {
) )
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
end, end,
last(betPoints)?.x, last(betPoints)?.x?.getTime(),
new Date(Date.now()) Date.now()
) )
const visibleRange = [start, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
const ChoiceTooltip = useMemo( const ChoiceTooltip = useMemo(
() => (props: TooltipProps<MultiPoint<Bet>>) => { () => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const legendItems = sortBy( const legendItems = sortBy(
y.map((p, i) => ({ data.y.map((p, i) => ({
color: CATEGORY_COLORS[i], color: CATEGORY_COLORS[i],
label: answers[i].text, label: answers[i].text,
value: formatPct(p), value: formatPct(p),
@ -172,32 +191,34 @@ export const ChoiceContractChart = (props: {
(item) => -item.p (item) => -item.p
).slice(0, 10) ).slice(0, 10)
return ( return (
<div> <>
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} {data.obj && (
<span>{formatDateInRange(x, start, end)}</span> <Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
)}
<span className="text-semibold text-base">
{formatDateInRange(d, start, end)}
</span>
</Row> </Row>
<Legend className="max-w-xs text-sm" items={legendItems} /> <Legend className="max-w-xs" items={legendItems} />
</div> </>
) )
}, },
[answers] [answers]
) )
return ( return (
<div ref={containerRef}> <MultiValueHistoryChart
{width > 0 && ( w={width}
<MultiValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} colors={CATEGORY_COLORS}
data={data} curve={curveStepAfter}
colors={CATEGORY_COLORS} onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip} Tooltip={ChoiceTooltip}
pct pct
/> />
)}
</div>
) )
} }

View File

@ -8,7 +8,8 @@ import { NumericContractChart } from './numeric'
export const ContractChart = (props: { export const ContractChart = (props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
}) => { }) => {
const { contract } = props const { contract } = props
switch (contract.outcomeType) { switch (contract.outcomeType) {

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { range } from 'lodash' import { range } from 'lodash'
import { scaleLinear } from 'd3-scale' import { scaleLinear } from 'd3-scale'
@ -6,10 +6,8 @@ import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers' import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts' import { DistributionPoint, DistributionChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
const getNumericChartData = (contract: NumericContract) => { const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract const { totalShares, bucketCount, min, max } = contract
@ -21,42 +19,41 @@ const getNumericChartData = (contract: NumericContract) => {
})) }))
} }
const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { const NumericChartTooltip = (
const { x, y } = props.p props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return ( return (
<span className="text-sm"> <>
<strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} <span className="text-semibold">{formatLargeNumber(x)}</span>
</span> <span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</>
) )
} }
export const NumericContractChart = (props: { export const NumericContractChart = (props: {
contract: NumericContract contract: NumericContract
height?: number width: number
height: number
onMouseOver?: (p: DistributionPoint | undefined) => void
}) => { }) => {
const { contract } = props const { contract, width, height, onMouseOver } = props
const { min, max } = contract const { min, max } = contract
const data = useMemo(() => getNumericChartData(contract), [contract]) const data = useMemo(() => getNumericChartData(contract), [contract])
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const maxY = Math.max(...data.map((d) => d.y)) const maxY = Math.max(...data.map((d) => d.y))
const xScale = scaleLinear([min, max], [0, width - MARGIN_X]) const xScale = scaleLinear([min, max], [0, width - MARGIN_X])
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <DistributionChart
{width > 0 && ( w={width}
<DistributionChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} color={NUMERIC_GRAPH_COLOR}
data={data} onMouseOver={onMouseOver}
color={NUMERIC_GRAPH_COLOR} Tooltip={NumericChartTooltip}
Tooltip={NumericChartTooltip} />
/>
)}
</div>
) )
} }

View File

@ -1,6 +1,7 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sortBy } from 'lodash' import { last, sortBy } from 'lodash'
import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
@ -8,7 +9,6 @@ import { getInitialProbability, getProbability } from 'common/calculate'
import { formatLargeNumber } from 'common/util/format' import { formatLargeNumber } from 'common/util/format'
import { PseudoNumericContract } from 'common/contract' import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { import {
TooltipProps, TooltipProps,
MARGIN_X, MARGIN_X,
@ -18,7 +18,6 @@ import {
formatDateInRange, formatDateInRange,
} from '../helpers' } from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -37,19 +36,21 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: scaleP(b.probAfter), y: scaleP(b.probAfter),
datum: b, obj: b,
})) }))
} }
const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const PseudoNumericChartTooltip = (
const { p, xScale } = props props: TooltipProps<Date, HistoryPoint<Bet>>
const { x, y, datum } = p ) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2 text-sm"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<strong>{formatLargeNumber(y)}</strong> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span>{formatDateInRange(x, start, end)}</span> <span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row> </Row>
) )
} }
@ -57,11 +58,13 @@ const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => {
export const PseudoNumericContractChart = (props: { export const PseudoNumericContractChart = (props: {
contract: PseudoNumericContract contract: PseudoNumericContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets } = props const { contract, bets, width, height, onMouseOver } = props
const { min, max, isLogScale } = contract const { min, max, isLogScale } = contract
const [startDate, endDate] = getDateRange(contract) const [start, end] = getDateRange(contract)
const scaleP = useMemo( const scaleP = useMemo(
() => getScaleP(min, max, isLogScale), () => getScaleP(min, max, isLogScale),
[min, max, isLogScale] [min, max, isLogScale]
@ -71,41 +74,34 @@ export const PseudoNumericContractChart = (props: {
const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP]) const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP])
const data = useMemo( const data = useMemo(
() => [ () => [
{ x: startDate, y: startP }, { x: new Date(start), y: startP },
...betPoints, ...betPoints,
{ x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, { x: new Date(end ?? Date.now() + DAY_MS), y: endP },
], ],
[betPoints, startDate, startP, endDate, endP] [betPoints, start, startP, end, endP]
) )
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
endDate, end,
last(betPoints)?.x, last(betPoints)?.x?.getTime(),
new Date(Date.now()) Date.now()
) )
const visibleRange = [startDate, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
// clamp log scale to make sure zeroes go to the bottom // clamp log scale to make sure zeroes go to the bottom
const yScale = isLogScale const yScale = isLogScale
? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true)
: scaleLinear([min, max], [height - MARGIN_Y, 0]) : scaleLinear([min, max], [height - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <SingleValueHistoryChart
{width > 0 && ( w={width}
<SingleValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} curve={curveStepAfter}
data={data} onMouseOver={onMouseOver}
Tooltip={PseudoNumericChartTooltip} Tooltip={PseudoNumericChartTooltip}
color={NUMERIC_GRAPH_COLOR} color={NUMERIC_GRAPH_COLOR}
/> />
)}
</div>
) )
} }

View File

@ -4,15 +4,16 @@ import { axisBottom, axisLeft } from 'd3-axis'
import { D3BrushEvent } from 'd3-brush' import { D3BrushEvent } from 'd3-brush'
import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
import { import {
CurveFactory,
SeriesPoint,
curveLinear, curveLinear,
curveStepAfter,
stack, stack,
stackOrderReverse, stackOrderReverse,
SeriesPoint,
} from 'd3-shape' } from 'd3-shape'
import { range } from 'lodash' import { range } from 'lodash'
import { import {
ContinuousScale,
SVGChart, SVGChart,
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
@ -31,6 +32,19 @@ const getTickValues = (min: number, max: number, n: number) => {
return [min, ...range(1, n - 1).map((i) => min + step * i), max] return [min, ...range(1, n - 1).map((i) => min + step * i), max]
} }
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
}
}
export const DistributionChart = <P extends DistributionPoint>(props: { export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[] data: P[]
w: number w: number
@ -38,9 +52,11 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
color: string color: string
xScale: ScaleContinuousNumeric<number, number> xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
Tooltip?: TooltipComponent<P> curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => { }) => {
const { color, data, yScale, w, h, Tooltip } = props const { color, data, yScale, w, h, curve, Tooltip } = props
const [viewXScale, setViewXScale] = const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>() useState<ScaleContinuousNumeric<number, number>>()
@ -49,7 +65,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100) const xAxis = axisBottom<number>(xScale).ticks(w / 100)
@ -57,6 +72,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, xScale, yScale]) }, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -68,17 +85,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
if (item == null) {
// this can happen if you are on the very left or right edge of the chart,
// so your queryX is out of bounds
return
}
return { ...item, x: queryX }
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -95,7 +101,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
px={px} px={px}
py0={py0} py0={py0}
py1={py1} py1={py1}
curve={curveLinear} curve={curve ?? curveLinear}
/> />
</SVGChart> </SVGChart>
) )
@ -108,10 +114,12 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
colors: readonly string[] colors: readonly string[]
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
Tooltip?: TooltipComponent<P> curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { colors, data, yScale, w, h, Tooltip, pct } = props const { colors, data, yScale, w, h, curve, Tooltip, pct } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -120,7 +128,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -142,6 +149,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data) return d3Stack(data)
}, [data]) }, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -153,17 +162,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
if (item == null) {
// this can happen if you are on the very left or right edge of the chart,
// so your queryX is out of bounds
return
}
return { ...item, x: queryX }
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -181,7 +179,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
px={px} px={px}
py0={py0} py0={py0}
py1={py1} py1={py1}
curve={curveStepAfter} curve={curve ?? curveLinear}
fill={colors[i]} fill={colors[i]}
/> />
))} ))}
@ -196,10 +194,12 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
color: string color: string
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
Tooltip?: TooltipComponent<P> curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, pct, yScale, w, h, Tooltip } = props const { color, data, yScale, w, h, curve, Tooltip, pct } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -207,7 +207,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -221,6 +220,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale]) }, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -232,17 +233,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
if (item == null) {
// this can happen if you are on the very left or right edge of the chart,
// so your queryX is out of bounds
return
}
return { ...item, x: queryX }
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -259,7 +249,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
px={px} px={px}
py0={py0} py0={py0}
py1={py1} py1={py1}
curve={curveStepAfter} curve={curve ?? curveLinear}
/> />
</SVGChart> </SVGChart>
) )

View File

@ -10,21 +10,28 @@ import {
import { pointer, select } from 'd3-selection' import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis' import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush' import { brushX, D3BrushEvent } from 'd3-brush'
import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { area, line, CurveFactory } from 'd3-shape'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Row } from 'web/components/layout/row' import { useMeasureSize } from 'web/hooks/use-measure-size'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
export interface ContinuousScale<T> extends AxisScale<T> {
invert(n: number): T
}
export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T }
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
export const MARGIN_X = MARGIN.right + MARGIN.left export const MARGIN_X = MARGIN.right + MARGIN.left
export const MARGIN_Y = MARGIN.top + MARGIN.bottom export const MARGIN_Y = MARGIN.top + MARGIN.bottom
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
const { h, axis } = props const { h, axis } = props
@ -66,11 +73,11 @@ const LinePathInternal = <P,>(
data: P[] data: P[]
px: number | ((p: P) => number) px: number | ((p: P) => number)
py: number | ((p: P) => number) py: number | ((p: P) => number)
curve?: CurveFactory curve: CurveFactory
} & SVGProps<SVGPathElement> } & SVGProps<SVGPathElement>
) => { ) => {
const { data, px, py, curve, ...rest } = props const { data, px, py, curve, ...rest } = props
const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter) const d3Line = line<P>(px, py).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} fill="none" d={d3Line(data)!} /> return <path {...rest} fill="none" d={d3Line(data)!} />
} }
@ -82,11 +89,11 @@ const AreaPathInternal = <P,>(
px: number | ((p: P) => number) px: number | ((p: P) => number)
py0: number | ((p: P) => number) py0: number | ((p: P) => number)
py1: number | ((p: P) => number) py1: number | ((p: P) => number)
curve?: CurveFactory curve: CurveFactory
} & SVGProps<SVGPathElement> } & SVGProps<SVGPathElement>
) => { ) => {
const { data, px, py0, py1, curve, ...rest } = props const { data, px, py0, py1, curve, ...rest } = props
const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter) const d3Area = area<P>(px, py0, py1).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} d={d3Area(data)!} /> return <path {...rest} d={d3Area(data)!} />
} }
@ -98,7 +105,7 @@ export const AreaWithTopStroke = <P,>(props: {
px: number | ((p: P) => number) px: number | ((p: P) => number)
py0: number | ((p: P) => number) py0: number | ((p: P) => number)
py1: number | ((p: P) => number) py1: number | ((p: P) => number)
curve?: CurveFactory curve: CurveFactory
}) => { }) => {
const { color, data, px, py0, py1, curve } = props const { color, data, px, py0, py1, curve } = props
return ( return (
@ -110,25 +117,26 @@ export const AreaWithTopStroke = <P,>(props: {
py1={py1} py1={py1}
curve={curve} curve={curve}
fill={color} fill={color}
opacity={0.3} opacity={0.2}
/> />
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} /> <LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
</g> </g>
) )
} }
export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>() const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y const innerH = h - MARGIN_Y
@ -147,7 +155,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouseState(undefined) setMouse(undefined)
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -167,32 +175,47 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev) const [x, y] = pointer(ev)
const p = onMouseOver(mouseX, mouseY) const data = onMouseOver(x, y)
if (p != null) { if (data !== undefined) {
setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) setMouse({ x, y, data })
} else { } else {
setMouseState(undefined) setMouse(undefined)
} }
} }
} }
const onPointerLeave = () => { const onPointerLeave = () => {
setMouseState(undefined) setMouse(undefined)
} }
return ( return (
<div className="relative"> <div className="relative overflow-hidden">
{mouseState && Tooltip && ( {mouse && Tooltip && (
<TooltipContainer top={mouseState.top} left={mouseState.left}> <TooltipContainer
<Tooltip xScale={xAxis.scale()} p={mouseState.p} /> setElem={tooltipMeasure.setElem}
pos={getTooltipPosition(
mouse.x,
mouse.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer> </TooltipContainer>
)} )}
<svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<clipPath id={clipPathId}> <clipPath id={clipPathId}>
<rect x={0} y={0} width={innerW} height={innerH} /> <rect x={0} y={0} width={innerW} height={innerH} />
</clipPath> </clipPath>
<g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> <g transform={MARGIN_XFORM}>
<XAxis axis={xAxis} w={innerW} h={innerH} /> <XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g> <g clipPath={`url(#${clipPathId})`}>{children}</g>
@ -214,64 +237,79 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
) )
} }
export type TooltipProps<P> = { p: P; xScale: XScale<P> } export type TooltipPosition = { left: number; bottom: number }
export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>>
export type TooltipPosition = { top: number; left: number } export const getTooltipPosition = (
export const TooltipContainer = ( mouseX: number,
props: TooltipPosition & { className?: string; children: React.ReactNode } mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
) => { ) => {
const { top, left, className, children } = props let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition
className?: string
children: React.ReactNode
}) => {
const { setElem, pos, className, children } = props
return ( return (
<div <div
ref={setElem}
className={clsx( className={clsx(
className, className,
'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2' 'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
)} )}
style={{ top, left }} style={{ margin: MARGIN_STYLE, ...pos }}
> >
{children} {children}
</div> </div>
) )
} }
export type LegendItem = { color: string; label: string; value?: string }
export const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
<ol className={className}>
{items.map((item) => (
<li key={item.label} className="flex flex-row justify-between">
<Row className="mr-2 items-center overflow-hidden">
<span
className="mr-2 h-4 w-4 shrink-0"
style={{ backgroundColor: item.color }}
></span>
<span className="overflow-hidden text-ellipsis">{item.label}</span>
</Row>
{item.value}
</li>
))}
</ol>
)
}
export const getDateRange = (contract: Contract) => { export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract const { createdTime, closeTime, resolutionTime } = contract
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && Date.now() > closeTime
const endDate = resolutionTime ?? (isClosed ? closeTime : null) const endDate = resolutionTime ?? (isClosed ? closeTime : null)
return [new Date(createdTime), endDate ? new Date(endDate) : null] as const return [createdTime, endDate ?? null] as const
} }
export const getRightmostVisibleDate = ( export const getRightmostVisibleDate = (
contractEnd: Date | null | undefined, contractEnd: number | null | undefined,
lastActivity: Date | null | undefined, lastActivity: number | null | undefined,
now: Date now: number
) => { ) => {
if (contractEnd != null) { if (contractEnd != null) {
return contractEnd return contractEnd
} else if (lastActivity != null) { } else if (lastActivity != null) {
// client-DB clock divergence may cause last activity to be later than now // client-DB clock divergence may cause last activity to be later than now
return new Date(Math.max(lastActivity.getTime(), now.getTime())) return Math.max(lastActivity, now)
} else { } else {
return now return now
} }

View File

@ -0,0 +1,76 @@
import { useMemo } from 'react'
import { scaleTime, scaleLinear } from 'd3-scale'
import { min, max } from 'lodash'
import dayjs from 'dayjs'
import { formatPercent } from 'common/util/format'
import { Row } from '../layout/row'
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers'
import { SizedContainer } from 'web/components/sized-container'
const getPoints = (startDate: number, dailyValues: number[]) => {
const startDateDayJs = dayjs(startDate)
return dailyValues.map((y, i) => ({
x: startDateDayJs.add(i, 'day').toDate(),
y: y,
}))
}
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{data.y}</span>
</Row>
)
}
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{formatPercent(data.y)}</span>
</Row>
)
}
export function DailyChart(props: {
startDate: number
dailyValues: number[]
excludeFirstDays?: number
pct?: boolean
}) {
const { dailyValues, startDate, excludeFirstDays, pct } = props
const data = useMemo(
() => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0),
[startDate, dailyValues, excludeFirstDays]
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minDate = min(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxDate = max(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxValue = max(data.map((d) => d.y))!
return (
<SizedContainer fullHeight={250} mobileHeight={250}>
{(width, height) => (
<SingleValueHistoryChart
w={width}
h={height}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
data={data}
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
color="#11b981"
pct={pct}
/>
)}
</SizedContainer>
)
}

View File

@ -126,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}> <TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <button
className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300" className="px-2 text-gray-400 hover:text-gray-500 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty} disabled={!editor || editor.isEmpty}
onClick={submit} onClick={submit}
> >

View File

@ -1,5 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { Button, ColorType, SizeType } from './button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -9,6 +10,9 @@ export function ConfirmationButton(props: {
label: string label: string
icon?: JSX.Element icon?: JSX.Element
className?: string className?: string
color?: ColorType
size?: SizeType
disabled?: boolean
} }
cancelBtn?: { cancelBtn?: {
label?: string label?: string
@ -22,6 +26,7 @@ export function ConfirmationButton(props: {
onSubmit?: () => void onSubmit?: () => void
onOpenChanged?: (isOpen: boolean) => void onOpenChanged?: (isOpen: boolean) => void
onSubmitWithSuccess?: () => Promise<boolean> onSubmitWithSuccess?: () => Promise<boolean>
disabled?: boolean
}) { }) {
const { const {
openModalBtn, openModalBtn,
@ -31,6 +36,7 @@ export function ConfirmationButton(props: {
children, children,
onOpenChanged, onOpenChanged,
onSubmitWithSuccess, onSubmitWithSuccess,
disabled,
} = props } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -68,13 +74,22 @@ export function ConfirmationButton(props: {
</Row> </Row>
</Col> </Col>
</Modal> </Modal>
<div
className={clsx('btn', openModalBtn.className)} <Button
onClick={() => updateOpen(true)} className={openModalBtn.className}
onClick={() => {
if (disabled) {
return
}
updateOpen(true)
}}
disabled={openModalBtn.disabled}
color={openModalBtn.color}
size={openModalBtn.size}
> >
{openModalBtn.icon} {openModalBtn.icon}
{openModalBtn.label} {openModalBtn.label}
</div> </Button>
</> </>
) )
} }
@ -84,18 +99,25 @@ export function ResolveConfirmationButton(props: {
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClass?: string submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) { }) {
const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } = const {
props onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx( className: clsx('border-none self-start', openModalButtonClass),
'border-none self-start',
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: 'Resolve', label: 'Resolve',
color: color,
disabled: isSubmitting || disabled,
size: 'xl',
}} }}
cancelBtn={{ cancelBtn={{
label: 'Back', label: 'Back',

View File

@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import { import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { import {
@ -82,7 +79,7 @@ export function ContractSearch(props: {
defaultFilter?: filter defaultFilter?: filter
defaultPill?: string defaultPill?: string
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
cardUIOptions?: { cardUIOptions?: {

View File

@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true, noLinkAvatar: true,
}} }}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), itemIds: contracts.map((c) => c.id),
highlightClassName: highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}

View File

@ -0,0 +1,74 @@
import { Contract } from 'common/contract'
import { useUser } from 'web/hooks/use-user'
import { useState } from 'react'
import { addCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button'
export function AddCommentBountyPanel(props: { contract: Contract }) {
const { contract } = props
const { id: contractId, slug } = contract
const user = useUser()
const amount = COMMENT_BOUNTY_AMOUNT
const totalAdded = contract.openCommentBounties ?? 0
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const submit = () => {
if ((user?.balance ?? 0) < amount) {
setError('Insufficient balance')
return
}
setIsLoading(true)
setIsSuccess(false)
addCommentBounty({ amount, contractId })
.then((_) => {
track('offer comment bounty', {
amount,
contractId,
})
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('add comment bounty', { amount, contractId, slug })
}
return (
<>
<div className="mb-4 text-gray-500">
Add a {formatMoney(amount)} bounty for good comments that the creator
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
<Row className={'items-center gap-2'}>
<Button
className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}

View File

@ -0,0 +1,47 @@
import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
export function BountiedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span>
)
}
export function BountiedContractSmallBadge(props: {
contract: Contract
showAmount?: boolean
}) {
const { contract, showAmount } = props
const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return (
<Tooltip
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
)
}
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties
)} currently available.`

View File

@ -46,6 +46,7 @@ export function ContractCard(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
trackingPostfix?: string trackingPostfix?: string
noLinkAvatar?: boolean noLinkAvatar?: boolean
newTab?: boolean
}) { }) {
const { const {
showTime, showTime,
@ -56,6 +57,7 @@ export function ContractCard(props: {
hideGroupLink, hideGroupLink,
trackingPostfix, trackingPostfix,
noLinkAvatar, noLinkAvatar,
newTab,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -189,6 +191,7 @@ export function ContractCard(props: {
} }
)} )}
className="absolute top-0 left-0 right-0 bottom-0" className="absolute top-0 left-0 right-0 bottom-0"
target={newTab ? '_blank' : '_self'}
/> />
</Link> </Link>
)} )}
@ -211,19 +214,23 @@ export function BinaryResolutionOrChance(props: {
const probChanged = before !== after const probChanged = before !== after
return ( return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> <Col
className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)}
>
{resolution ? ( {resolution ? (
<> <Row className="flex items-start">
<div <div>
className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')} <div
> className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')}
Resolved >
Resolved
</div>
<BinaryContractOutcomeLabel
contract={contract}
resolution={resolution}
/>
</div> </div>
<BinaryContractOutcomeLabel </Row>
contract={contract}
resolution={resolution}
/>
</>
) : ( ) : (
<> <>
{probAfter && probChanged ? ( {probAfter && probChanged ? (
@ -388,7 +395,9 @@ export function ContractCardProbChange(props: {
noLinkAvatar?: boolean noLinkAvatar?: boolean
className?: string className?: string
}) { }) {
const { contract, noLinkAvatar, className } = props const { noLinkAvatar, className } = props
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
return ( return (
<Col <Col
className={clsx( className={clsx(

View File

@ -14,7 +14,7 @@ import { useState } from 'react'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
import { MiniUserFollowButton } from '../follow-button' import { MiniUserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserById } from 'web/hooks/use-user'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
@ -28,10 +28,14 @@ import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip' import { Tooltip } from 'web/components/tooltip'
import { ExtraContractActionsRow } from './extra-contract-actions-row' import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { PlusCircleIcon } from '@heroicons/react/solid' import { ExclamationIcon, PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle' import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
import {
BountiedContractBadge,
BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -63,6 +67,8 @@ export function MiscDetails(props: {
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : ( ) : (
@ -126,9 +132,10 @@ export function ContractDetails(props: {
</Row> </Row>
{/* GROUPS */} {/* GROUPS */}
{isMobile && ( {isMobile && (
<div className="mt-2"> <Row className="mt-2 gap-1">
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} /> <MarketGroups contract={contract} disabled={disabled} />
</div> </Row>
)} )}
</Col> </Col>
) )
@ -142,6 +149,8 @@ export function MarketSubheader(props: {
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract) const { resolvedDate } = contractMetrics(contract)
const user = useUser() const user = useUser()
const correctResolutionPercentage =
useUserById(creatorId)?.fractionResolvedCorrectly
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isMobile = useIsMobile() const isMobile = useIsMobile()
return ( return (
@ -153,13 +162,14 @@ export function MarketSubheader(props: {
size={9} size={9}
className="mr-1.5" className="mr-1.5"
/> />
{!disabled && ( {!disabled && (
<div className="absolute mt-3 ml-[11px]"> <div className="absolute mt-3 ml-[11px]">
<MiniUserFollowButton userId={creatorId} /> <MiniUserFollowButton userId={creatorId} />
</div> </div>
)} )}
<Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm"> <Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm">
<Row className="w-full justify-between "> <Row className="w-full space-x-1 ">
{disabled ? ( {disabled ? (
creatorName creatorName
) : ( ) : (
@ -170,15 +180,25 @@ export function MarketSubheader(props: {
short={isMobile} short={isMobile}
/> />
)} )}
{correctResolutionPercentage != null &&
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
<Tooltip text="This creator has a track record of creating contracts that are resolved incorrectly.">
<ExclamationIcon className="h-6 w-6 text-yellow-500" />
</Tooltip>
)}
</Row> </Row>
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs"> <Row className="text-2xs text-greyscale-4 flex-wrap gap-2 sm:text-xs">
<CloseOrResolveTime <CloseOrResolveTime
contract={contract} contract={contract}
resolvedDate={resolvedDate} resolvedDate={resolvedDate}
isCreator={isCreator} isCreator={isCreator}
disabled={disabled}
/> />
{!isMobile && ( {!isMobile && (
<MarketGroups contract={contract} disabled={disabled} /> <Row className={'gap-1'}>
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} />
</Row>
)} )}
</Row> </Row>
</Col> </Col>
@ -190,8 +210,9 @@ export function CloseOrResolveTime(props: {
contract: Contract contract: Contract
resolvedDate: any resolvedDate: any
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { contract, resolvedDate, isCreator } = props const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) { if (!!closeTime || !!resolvedDate) {
return ( return (
@ -215,6 +236,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
isCreator={isCreator ?? false} isCreator={isCreator ?? false}
disabled={disabled}
/> />
</Row> </Row>
)} )}
@ -235,7 +257,8 @@ export function MarketGroups(props: {
return ( return (
<> <>
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} /> <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && ( {!disabled && user && (
<button <button
className="text-greyscale-4 hover:text-greyscale-3" className="text-greyscale-4 hover:text-greyscale-3"
@ -320,19 +343,34 @@ export function ExtraMobileContractDetails(props: {
) )
} }
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { export function GroupDisplay(props: {
const { groupToDisplay } = props groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
if (groupToDisplay) { if (groupToDisplay) {
return ( const groupSection = (
<a
className={clsx(
'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
!disabled && 'hover:bg-greyscale-3 cursor-pointer'
)}
>
{groupToDisplay.name}
</a>
)
return disabled ? (
groupSection
) : (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}> <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]"> {groupSection}
{groupToDisplay.name}
</a>
</Link> </Link>
) )
} else } else
return ( return (
<div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white"> <div className="bg-greyscale-4 truncate rounded-full py-0.5 px-2 text-xs text-white">
No Group No Group
</div> </div>
) )
@ -342,8 +380,9 @@ function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { closeTime, contract, isCreator } = props const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime) const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs() const dayJsNow = dayjs()
@ -356,18 +395,22 @@ function EditableCloseDate(props: {
closeTime && dayJsCloseTime.format('HH:mm') closeTime && dayJsCloseTime.format('HH:mm')
) )
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => { let newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
function onSave(customTime?: number) {
if (customTime) {
newCloseTime = customTime
setCloseDate(dayjs(newCloseTime).format('YYYY-MM-DD'))
setCloseHoursMinutes(dayjs(newCloseTime).format('HH:mm'))
}
if (!newCloseTime) return if (!newCloseTime) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false) if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) { else {
const content = contract.description const content = contract.description
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
@ -416,13 +459,21 @@ function EditableCloseDate(props: {
/> />
</Row> </Row>
<Button <Button
className="mt-2" className="mt-4"
size={'xs'} size={'xs'}
color={'indigo'} color={'indigo'}
onClick={onSave} onClick={() => onSave()}
> >
Done Done
</Button> </Button>
<Button
className="mt-4"
size={'xs'}
color={'gray-white'}
onClick={() => onSave(Date.now())}
>
Close Now
</Button>
</Col> </Col>
</Modal> </Modal>
<DateTimeTooltip <DateTimeTooltip
@ -430,8 +481,8 @@ function EditableCloseDate(props: {
time={closeTime} time={closeTime}
> >
<span <span
className={isCreator ? 'cursor-pointer' : ''} className={!disabled && isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)} onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
> >
{isSameDay ? ( {isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span> <span className={'capitalize'}> {fromNow(closeTime)}</span>
@ -445,3 +496,5 @@ function EditableCloseDate(props: {
</> </>
) )
} }
const BAD_CREATOR_THRESHOLD = 0.8

View File

@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel' import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Title } from '../title' import { Title } from '../title'
@ -19,7 +19,7 @@ import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle' import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { BETTORS } from 'common/user' import { BETTORS, User } from 'common/user'
import { Button } from '../button' import { Button } from '../button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
@ -27,9 +27,10 @@ export const contractDetailsButtonClassName =
export function ContractInfoDialog(props: { export function ContractInfoDialog(props: {
contract: Contract contract: Contract
user: User | null | undefined
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState( const [featured, setFeatured] = useState(
@ -37,6 +38,11 @@ export function ContractInfoDialog(props: {
) )
const isDev = useDev() const isDev = useDev()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const isCreator = user?.id === contract.creatorId
const isUnlisted = contract.visibility === 'unlisted'
const wasUnlistedByCreator = contract.unlistedById
? contract.unlistedById === contract.creatorId
: false
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
@ -168,22 +174,28 @@ export function ContractInfoDialog(props: {
<td>[ADMIN] Featured</td> <td>[ADMIN] Featured</td>
<td> <td>
<ShortToggle <ShortToggle
enabled={featured} on={featured}
setEnabled={setFeatured} setOn={setFeatured}
onChange={onFeaturedToggle} onChange={onFeaturedToggle}
/> />
</td> </td>
</tr> </tr>
)} )}
{isAdmin && ( {user && (
<tr> <tr>
<td>[ADMIN] Unlisted</td> <td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td>
<td> <td>
<ShortToggle <ShortToggle
enabled={contract.visibility === 'unlisted'} disabled={
setEnabled={(b) => isUnlisted
? !(isAdmin || (isCreator && wasUnlistedByCreator))
: !(isCreator || isAdmin)
}
on={contract.visibility === 'unlisted'}
setOn={(b) =>
updateContract(id, { updateContract(id, {
visibility: b ? 'unlisted' : 'public', visibility: b ? 'unlisted' : 'public',
unlistedById: b ? user.id : '',
}) })
} }
/> />
@ -196,9 +208,7 @@ export function ContractInfoDialog(props: {
<Row className="flex-wrap"> <Row className="flex-wrap">
<DuplicateContractButton contract={contract} /> <DuplicateContractButton contract={contract} />
</Row> </Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {!contract.resolution && <LiquidityBountyPanel contract={contract} />}
<LiquidityPanel contract={contract} />
)}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

@ -1,13 +1,6 @@
import React from 'react'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import { ContractChart } from 'web/components/charts/contract'
BinaryContractChart,
NumericContractChart,
PseudoNumericContractChart,
ChoiceContractChart,
} from 'web/components/charts/contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
@ -29,6 +22,8 @@ import {
BinaryContract, BinaryContract,
} from 'common/contract' } from 'common/contract'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { ContractReportResolution } from './contract-report-resolution'
import { SizedContainer } from 'web/components/sized-container'
const OverviewQuestion = (props: { text: string }) => ( const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
@ -48,8 +43,29 @@ const BetWidget = (props: { contract: CPMMContract }) => {
) )
} }
const NumericOverview = (props: { contract: NumericContract }) => { const SizedContractChart = (props: {
const { contract } = props contract: Contract
bets: Bet[]
fullHeight: number
mobileHeight: number
}) => {
const { fullHeight, mobileHeight, contract, bets } = props
return (
<SizedContainer fullHeight={fullHeight} mobileHeight={mobileHeight}>
{(width, height) => (
<ContractChart
width={width}
height={height}
contract={contract}
bets={bets}
/>
)}
</SizedContainer>
)
}
const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => {
const { contract, bets } = props
return ( return (
<Col className="gap-1 md:gap-2"> <Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4"> <Col className="gap-3 px-2 sm:gap-4">
@ -66,7 +82,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract} contract={contract}
/> />
</Col> </Col>
<NumericContractChart contract={contract} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -79,14 +100,24 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<ContractDetails contract={contract} /> <ContractDetails contract={contract} />
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} /> <OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance <Row>
className="flex items-end" <BinaryResolutionOrChance
contract={contract} className="flex items-end"
large contract={contract}
/> large
/>
{contract.isResolved && (
<ContractReportResolution contract={contract} />
)}
</Row>
</Row> </Row>
</Col> </Col>
<BinaryContractChart contract={contract} bets={bets} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} /> <BinaryMobileBetting contract={contract} />
@ -108,12 +139,21 @@ const ChoiceOverview = (props: {
<ContractDetails contract={contract} /> <ContractDetails contract={contract} />
<OverviewQuestion text={question} /> <OverviewQuestion text={question} />
{resolution && ( {resolution && (
<FreeResponseResolutionOrChance contract={contract} truncate="none" /> <Row>
<FreeResponseResolutionOrChance
contract={contract}
truncate="none"
/>
<ContractReportResolution contract={contract} />
</Row>
)} )}
</Col> </Col>
<Col className={'mb-1 gap-y-2'}> <SizedContractChart
<ChoiceContractChart contract={contract} bets={bets} /> contract={contract}
</Col> bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col> </Col>
) )
} }
@ -139,7 +179,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />} {tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row> </Row>
</Col> </Col>
<PseudoNumericContractChart contract={contract} bets={bets} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -153,7 +198,7 @@ export const ContractOverview = (props: {
case 'BINARY': case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} /> return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC': case 'NUMERIC':
return <NumericOverview contract={contract} /> return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC': case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} /> return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE': case 'FREE_RESPONSE':

View File

@ -0,0 +1,77 @@
import { Contract } from 'common/contract'
import { useUser } from 'web/hooks/use-user'
import clsx from 'clsx'
import { updateContract } from 'web/lib/firebase/contracts'
import { Tooltip } from '../tooltip'
import { ConfirmationButton } from '../confirmation-button'
import { Row } from '../layout/row'
import { FlagIcon } from '@heroicons/react/solid'
import { buildArray } from 'common/util/array'
import { useState } from 'react'
export function ContractReportResolution(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const [reporting, setReporting] = useState(false)
if (!user) {
return <></>
}
const userReported = contract.flaggedByUsernames?.includes(user.id)
const onSubmit = async () => {
if (!user || userReported) {
return true
}
setReporting(true)
await updateContract(contract.id, {
flaggedByUsernames: buildArray(contract.flaggedByUsernames, user.id),
})
setReporting(false)
return true
}
const flagClass = clsx(
'mx-2 flex flex-col items-center gap-1 w-6 h-6 rounded-md !bg-gray-100 px-2 py-1 hover:bg-gray-300',
userReported ? '!text-red-500' : '!text-gray-500'
)
return (
<Tooltip
text={
userReported
? "You've reported this market as incorrectly resolved"
: 'Flag this market as incorrectly resolved '
}
>
<ConfirmationButton
openModalBtn={{
label: '',
icon: <FlagIcon className="h-5 w-5" />,
className: clsx(flagClass, reporting && 'btn-disabled loading'),
}}
cancelBtn={{
label: 'Cancel',
className: 'border-none btn-sm btn-ghost self-center',
}}
submitBtn={{
label: 'Submit',
className: 'btn-secondary',
}}
onSubmitWithSuccess={onSubmit}
disabled={userReported}
>
<div>
<Row className="items-center text-xl">
Flag this market as incorrectly resolved
</Row>
<Row className="text-sm text-gray-500">
Report that the market was not resolved according to its resolution
criteria. If a creator's markets get flagged too often, they'll be
marked as unreliable.
</Row>
</div>
</ConfirmationButton>
</Tooltip>
)
}

View File

@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
@ -25,6 +25,18 @@ import {
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip'
import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge'
import { Row } from '../layout/row'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
@ -46,7 +58,7 @@ export function ContractTabs(props: {
title: 'Comments', title: 'Comments',
content: <CommentsTabContent contract={contract} comments={comments} />, content: <CommentsTabContent contract={contract} comments={comments} />,
}, },
{ bets.length > 0 && {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />, content: <BetsTabContent contract={contract} bets={bets} />,
}, },
@ -68,13 +80,39 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
key: `contract-${contract.id}-comments-sort`,
store: storageStore(safeLocalStorage()),
})
const me = useUser()
if (comments == null) { if (comments == null) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
const sortedComments = sortBy(comments, (c) =>
sort === 'Newest'
? c.createdTime
: // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
tipsOrBountiesAwarded &&
c.createdTime > Date.now() - 10 * MINUTE_MS &&
c.userId === me?.id
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
)
const commentsByParent = groupBy(
sortedComments,
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? []
// Top level comments are reverse-chronological, while replies are chronological
if (sort === 'Newest') topLevelComments.reverse()
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
const generalComments = comments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
const sortedAnswers = sortBy( const sortedAnswers = sortBy(
contract.answers, contract.answers,
(a) => -getOutcomeProbability(contract, a.id) (a) => -getOutcomeProbability(contract, a.id)
@ -83,6 +121,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
comments, comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_' (c) => c.answerOutcome ?? c.betOutcome ?? '_'
) )
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
return ( return (
<> <>
{sortedAnswers.map((answer) => ( {sortedAnswers.map((answer) => (
@ -106,12 +147,12 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" /> <div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{generalComments.map((comment) => ( {generalTopLevelComments.map((comment) => (
<FeedCommentThread <FeedCommentThread
key={comment.id} key={comment.id}
contract={contract} contract={contract}
parentComment={comment} parentComment={comment}
threadComments={[]} threadComments={commentsByParent[comment.id] ?? []}
tips={tips} tips={tips}
/> />
))} ))}
@ -119,12 +160,53 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
</> </>
) )
} else { } else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_') // TODO: links to comments are broken because tips load after render and
// comments will reorganize themselves if there are tips/bounties awarded
const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
const commentsByParent = groupBy(
sortBy(comments, (c) =>
sort === 'Newest'
? -c.createdTime
: // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
tipsOrBountiesAwarded &&
c.createdTime > Date.now() - 10 * MINUTE_MS &&
c.userId === me?.id
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
),
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
return ( return (
<> <>
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
{comments.length > 0 && (
<Row className="mb-4 items-center">
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={
sort === 'Best'
? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: ''
}
>
Sort by: {sort}
</Tooltip>
</Button>
<BountiedContractSmallBadge contract={contract} showAmount />
</Row>
)}
{topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}
contract={contract} contract={contract}

View File

@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css' import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
export type ContractHighlightOptions = { export type CardHighlightOptions = {
contractIds?: string[] itemIds?: string[]
highlightClassName?: string highlightClassName?: string
} }
@ -28,7 +28,7 @@ export function ContractsGrid(props: {
noLinkAvatar?: boolean noLinkAvatar?: boolean
showProbChange?: boolean showProbChange?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
trackingPostfix?: string trackingPostfix?: string
breakpointColumns?: { [key: string]: number } breakpointColumns?: { [key: string]: number }
}) { }) {
@ -43,7 +43,7 @@ export function ContractsGrid(props: {
} = props } = props
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } = const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
cardUIOptions || {} cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {} const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback( const onVisibilityUpdated = useCallback(
(visible) => { (visible) => {
if (visible && loadMore) { if (visible && loadMore) {

View File

@ -18,9 +18,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
return ( return (
<Row> <Row>
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && ( <LikeMarketButton contract={contract} user={user} />
<LikeMarketButton contract={contract} user={user} />
)}
<Tooltip text="Share" placement="bottom" noTap noFade> <Tooltip text="Share" placement="bottom" noTap noFade>
<Button <Button
size="sm" size="sm"
@ -37,7 +35,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/> />
</Button> </Button>
</Tooltip> </Tooltip>
<ContractInfoDialog contract={contract} /> <ContractInfoDialog contract={contract} user={user} />
</Row> </Row>
) )
} }

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline' import React, { useMemo, useState } from 'react'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes' import { useUserLikes } from 'web/hooks/use-likes'
@ -8,74 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes' import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like' import { LIKE_TIP_AMOUNT } from 'common/like'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash' import { sum } from 'lodash'
import { Tooltip } from '../tooltip' import { TipButton } from './tip-button'
export function LikeMarketButton(props: { export function LikeMarketButton(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
}) { }) {
const { contract, user } = props const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id const tips = useMarketTipTxns(contract.id)
)
const totalTipped = useMemo(() => { const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount)) return sum(tips.map((tip) => tip.amount))
}, [tips]) }, [tips])
const likes = useUserLikes(user?.id) const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes const userLikedContractIds = likes
?.filter((l) => l.type === 'contract') ?.filter((l) => l.type === 'contract')
.map((l) => l.id) .map((l) => l.id)
const onLike = async () => { const onLike = async () => {
if (!user) return firebaseLogin() if (!user) return firebaseLogin()
await likeContract(user, contract)
setIsLiking(true)
likeContract(user, contract).catch(() => setIsLiking(false))
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
return ( return (
<Tooltip <TipButton
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`} onClick={onLike}
placement="bottom" tipAmount={LIKE_TIP_AMOUNT}
noTap totalTipped={totalTipped}
noFade userTipped={
> !!user &&
<Button (isLiking ||
size={'sm'} userLikedContractIds?.includes(contract.id) ||
className={'max-w-xs self-center'} (!likes && !!contract.likedByUserIds?.includes(user.id)))
color={'gray-white'} }
onClick={onLike} disabled={contract.creatorId === user?.id}
> />
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
) )
} }

View File

@ -1,27 +1,30 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CPMMContract } from 'common/contract' import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from './amount-input' import { AmountInput } from 'web/components/amount-input'
import { Row } from './layout/row' import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity' import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from './outcome-label' import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from './layout/col' import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user' import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
export function LiquidityPanel(props: { contract: CPMMContract }) { export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser() const user = useUser()
const lpShares = useUserLiquidity(contract, user?.id ?? '') // eslint-disable-next-line react-hooks/rules-of-hooks
const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '')
const [showWithdrawal, setShowWithdrawal] = useState(false) const [showWithdrawal, setShowWithdrawal] = useState(false)
@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
const isCreator = user?.id === contract.creatorId const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin() const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return ( return (
<Tabs <Tabs
tabs={buildArray( tabs={buildArray(
(isCreator || isAdmin) && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
showWithdrawal && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
{ {
title: 'Pool', title: 'Bounty Comments',
content: <ViewLiquidityPanel contract={contract} />, content: <AddCommentBountyPanel contract={contract} />,
} },
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
showWithdrawal &&
isCPMM && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
(isCreator || isAdmin) &&
isCPMM && {
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
}
)} )}
/> />
) )

View File

@ -0,0 +1,61 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney } from 'common/util/format'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
export function TipButton(props: {
tipAmount: number
totalTipped: number
onClick: () => void
userTipped: boolean
isCompact?: boolean
disabled?: boolean
}) {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
props
return (
<Tooltip
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={clsx(
'max-w-xs self-center',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick}
disabled={disabled}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
)
}

View File

@ -19,6 +19,7 @@ import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button'
export type ReplyTo = { id: string; username: string } export type ReplyTo = { id: string; username: string }
@ -85,6 +86,7 @@ export function FeedComment(props: {
commenterPositionShares, commenterPositionShares,
commenterPositionOutcome, commenterPositionOutcome,
createdTime, createdTime,
bountiesAwarded,
} = comment } = comment
const betOutcome = comment.betOutcome const betOutcome = comment.betOutcome
let bought: string | undefined let bought: string | undefined
@ -93,6 +95,7 @@ export function FeedComment(props: {
bought = comment.betAmount >= 0 ? 'bought' : 'sold' bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount)) money = formatMoney(Math.abs(comment.betAmount))
} }
const totalAwarded = bountiesAwarded ?? 0
const router = useRouter() const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`) const highlighted = router.asPath.endsWith(`#${comment.id}`)
@ -162,6 +165,11 @@ export function FeedComment(props: {
createdTime={createdTime} createdTime={createdTime}
elementId={comment.id} elementId={comment.id}
/> />
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div> </div>
<Content <Content
className="mt-2 text-[15px] text-gray-700" className="mt-2 text-[15px] text-gray-700"
@ -169,7 +177,6 @@ export function FeedComment(props: {
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Row className="mt-2 items-center gap-6 text-xs text-gray-500">
{tips && <Tipper comment={comment} tips={tips} />}
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
@ -178,6 +185,10 @@ export function FeedComment(props: {
Reply Reply
</button> </button>
)} )}
{tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row> </Row>
</div> </div>
</Row> </Row>
@ -208,28 +219,32 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const user = useUser() const user = useUser()
const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } =
props
const { openCommentBounties } = contract
async function onSubmitComment(editor: Editor) { async function onSubmitComment(editor: Editor) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
await createCommentOnContract( await createCommentOnContract(
props.contract.id, contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
props.parentAnswerOutcome, !!openCommentBounties,
props.parentCommentId parentAnswerOutcome,
parentCommentId
) )
props.onSubmitComment?.() props.onSubmitComment?.()
} }
return ( return (
<CommentInput <CommentInput
replyTo={props.replyTo} replyTo={replyTo}
parentAnswerOutcome={props.parentAnswerOutcome} parentAnswerOutcome={parentAnswerOutcome}
parentCommentId={props.parentCommentId} parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}
className={props.className} className={className}
/> />
) )
} }

View File

@ -82,11 +82,8 @@ export function CreateGroupButton(props: {
openModalBtn={{ openModalBtn={{
label: label ? label : 'Create Group', label: label ? label : 'Create Group',
icon: icon, icon: icon,
className: clsx( className: className,
isSubmitting ? 'loading btn-disabled' : 'btn-primary', disabled: isSubmitting,
'btn-sm, normal-case',
className
),
}} }}
submitBtn={{ submitBtn={{
label: 'Create', label: 'Create',

View File

@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react' import { useState } from 'react'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: { export function GroupOverviewPost(props: {
group: Group group: Group
isEditable: boolean isEditable: boolean
post: Post | null post: Post | null

View File

@ -0,0 +1,383 @@
import { track } from '@amplitude/analytics-browser'
import {
ArrowSmRightIcon,
PlusCircleIcon,
XCircleIcon,
} from '@heroicons/react/outline'
import PencilIcon from '@heroicons/react/solid/PencilIcon'
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useEffect, useState } from 'react'
import { ReactNode } from 'react'
import { getPost } from 'web/lib/firebase/posts'
import { ContractSearch } from '../contract-search'
import { ContractCard } from '../contract/contract-card'
import Masonry from 'react-masonry-css'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { SiteLink } from '../site-link'
import { GroupOverviewPost } from './group-overview-post'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { groupPath, updateGroup } from 'web/lib/firebase/groups'
import { PinnedSelectModal } from '../pinned-select-modal'
import { Button } from '../button'
import { User } from 'common/user'
import { UserLink } from '../user-link'
import { EditGroupButton } from './edit-group-button'
import { JoinOrLeaveGroupButton } from './groups-button'
import { Linkify } from '../linkify'
import { ChoicesToggleGroup } from '../choices-toggle-group'
import { CopyLinkButton } from '../copy-link-button'
import { REFERRAL_AMOUNT } from 'common/economy'
import toast from 'react-hot-toast'
import { ENV_CONFIG } from 'common/envs/constants'
import { PostCard } from '../post-card'
import { LoadingIndicator } from '../loading-indicator'
const MAX_TRENDING_POSTS = 6
export function GroupOverview(props: {
group: Group
isEditable: boolean
posts: Post[]
aboutPost: Post | null
creator: User
user: User | null | undefined
memberIds: string[]
}) {
const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
props
return (
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
<GroupOverviewPinned
group={group}
posts={posts}
isEditable={isEditable}
/>
{(group.aboutPostId != null || isEditable) && (
<>
<SectionHeader label={'About'} href={'/post/' + group.slug} />
<GroupOverviewPost
group={group}
isEditable={isEditable}
post={aboutPost}
/>
</>
)}
<SectionHeader label={'Trending'} />
<ContractSearch
user={user}
defaultSort={'score'}
noControls
maxResults={MAX_TRENDING_POSTS}
defaultFilter={'all'}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-trending-${group.slug}`}
/>
<GroupAbout
group={group}
creator={creator}
isEditable={isEditable}
user={user}
memberIds={memberIds}
/>
</Col>
)
}
function GroupOverviewPinned(props: {
group: Group
posts: Post[]
isEditable: boolean
}) {
const { group, posts, isEditable } = props
const [pinned, setPinned] = useState<JSX.Element[]>([])
const [open, setOpen] = useState(false)
const [editMode, setEditMode] = useState(false)
useEffect(() => {
async function getPinned() {
if (group.pinnedItems == null) {
updateGroup(group, { pinnedItems: [] })
} else {
const itemComponents = await Promise.all(
group.pinnedItems.map(async (element) => {
if (element.type === 'post') {
const post = await getPost(element.itemId)
if (post) {
return <PostCard post={post as Post} />
}
} else if (element.type === 'contract') {
const contract = await getContractFromId(element.itemId)
if (contract) {
return <ContractCard contract={contract as Contract} />
}
}
})
)
setPinned(
itemComponents.filter(
(element) => element != undefined
) as JSX.Element[]
)
}
}
getPinned()
}, [group, group.pinnedItems])
async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
await updateGroup(group, {
pinnedItems: [
...group.pinnedItems,
...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
],
})
setOpen(false)
}
return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? (
pinned.length > 0 || isEditable ? (
<div>
<Row className="mb-3 items-center justify-between">
<SectionHeader label={'Pinned'} />
{isEditable && (
<Button
color="gray"
size="xs"
onClick={() => {
setEditMode(!editMode)
}}
>
{editMode ? (
'Done'
) : (
<>
<PencilIcon className="inline h-4 w-4" />
Edit
</>
)}
</Button>
)}
</Row>
<div>
<Masonry
breakpointCols={{ default: 2, 768: 1 }}
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{pinned.length == 0 && !editMode && (
<div className="flex flex-col items-center justify-center">
<p className="text-center text-gray-400">
No pinned items yet. Click the edit button to add some!
</p>
</div>
)}
{pinned.map((element, index) => (
<div className="relative my-2">
{element}
{editMode && (
<CrossIcon
onClick={() => {
const newPinned = group.pinnedItems.filter((item) => {
return item.itemId !== group.pinnedItems[index].itemId
})
updateGroup(group, { pinnedItems: newPinned })
}}
/>
)}
</div>
))}
{editMode && group.pinnedItems && pinned.length < 6 && (
<div className=" py-2">
<Row
className={
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100'
}
>
<button
className="flex w-full justify-center"
onClick={() => setOpen(true)}
>
<PlusCircleIcon
className="h-12 w-12 text-gray-600"
aria-hidden="true"
/>
</button>
</Row>
</div>
)}
</Masonry>
</div>
<PinnedSelectModal
open={open}
group={group}
posts={posts}
setOpen={setOpen}
title="Pin a post or market"
description={
<div className={'text-md my-4 text-gray-600'}>
Pin posts or markets to the overview of this group.
</div>
}
onSubmit={onSubmit}
/>
</div>
) : (
<LoadingIndicator />
)
) : (
<></>
)
}
function SectionHeader(props: {
label: string
href?: string
children?: ReactNode
}) {
const { label, href, children } = props
const content = (
<>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</>
)
return (
<Row className="mb-3 items-center justify-between">
{href ? (
<SiteLink
className="text-xl"
href={href}
onClick={() => track('group click section header', { section: href })}
>
{content}
</SiteLink>
) : (
<span className="text-xl">{content}</span>
)}
{children}
</Row>
)
}
export function GroupAbout(props: {
group: Group
creator: User
user: User | null | undefined
isEditable: boolean
memberIds: string[]
}) {
const { group, creator, isEditable, user, memberIds } = props
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
}
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
function updateAnyoneCanJoin(newVal: boolean) {
if (group.anyoneCanJoin == newVal || !isEditable) return
setAnyoneCanJoin(newVal)
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
loading: 'Updating group...',
success: 'Updated group!',
error: "Couldn't update group",
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? memberIds.includes(user.id) : false
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
<Row className={'flex-wrap justify-between'}>
<div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</div>
{isEditable ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user && (
<Row>
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
</Row>
<div className={'block sm:hidden'}>
<Linkify text={group.about} />
</div>
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
<ChoicesToggleGroup
currentChoice={anyoneCanJoin.toString()}
choicesMap={anyoneCanJoinChoices}
setChoice={(choice) =>
updateAnyoneCanJoin(choice.toString() === 'true')
}
toggleClassName={'h-10'}
className={'ml-2'}
/>
) : (
<span className={'text-gray-700'}>
{anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
</Col>
</>
)
}
function CrossIcon(props: { onClick: () => void }) {
const { onClick } = props
return (
<div>
<button className=" text-gray-500 hover:text-gray-700" onClick={onClick}>
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-200 bg-opacity-50">
<XCircleIcon className="h-12 w-12 text-gray-600" />
</div>
</button>
</div>
)
}

View File

@ -32,27 +32,27 @@ export function GroupSelector(props: {
const openGroups = useOpenGroups() const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id) const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? [] const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
.sort((a, b) => b.totalContracts - a.totalContracts)
// put the groups the user is a member of first
.sort((a, b) => {
if (memberGroupIds.includes(a.id)) {
return -1
}
if (memberGroupIds.includes(b.id)) {
return 1
}
return 0
})
const filteredGroups = availableGroups.filter((group) => const sortGroups = (groups: Group[]) =>
searchInAny(query, group.name) groups.sort(
(a, b) =>
// weight group higher if user is a member
(memberGroupIds.includes(b.id) ? 5 : 1) * b.totalContracts -
(memberGroupIds.includes(a.id) ? 5 : 1) * a.totalContracts
)
const availableGroups = sortGroups(
openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
)
const filteredGroups = sortGroups(
availableGroups.filter((group) => searchInAny(query, group.name))
) )
if (!showSelector || !creator) { if (!showSelector || !creator) {

View File

@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { Col } from './col' import { Col } from './col'
import { Tooltip } from 'web/components/tooltip'
import { Row } from 'web/components/layout/row'
type Tab = { type Tab = {
title: string title: string
tabIcon?: ReactNode
content: ReactNode content: ReactNode
// If set, show a badge with this content stackedTabIcon?: ReactNode
badge?: string inlineTabIcon?: ReactNode
tooltip?: string
} }
type TabProps = { type TabProps = {
@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)} )}
aria-current={activeIndex === i ? 'page' : undefined} aria-current={activeIndex === i ? 'page' : undefined}
> >
{tab.badge ? (
<span className="px-0.5 font-bold">{tab.badge}</span>
) : null}
<Col> <Col>
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>} <Tooltip text={tab.tooltip}>
{tab.title} {tab.stackedTabIcon && (
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
<Row className={'gap-1 '}>
{tab.title}
{tab.inlineTabIcon}
</Row>
</Tooltip>
</Col> </Col>
</a> </a>
))} ))}

View File

@ -182,7 +182,7 @@ export function OrderBookButton(props: {
size="xs" size="xs"
color="blue" color="blue"
> >
Order book {limitBets.length} Limit orders
</Button> </Button>
<Modal open={open} setOpen={setOpen} size="lg"> <Modal open={open} setOpen={setOpen} size="lg">

View File

@ -20,7 +20,6 @@ import NotificationsIcon from 'web/components/notifications-icon'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon' import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
@ -143,14 +142,12 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, { name: 'Tournaments', href: '/tournaments' },
[ { name: 'Charity', href: '/charity' },
{ name: 'Tournaments', href: '/tournaments' }, { name: 'Labs', href: '/labs' },
{ name: 'Charity', href: '/charity' }, { name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
) )
} }
@ -158,20 +155,16 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, { name: 'Referrals', href: '/referrals' },
[ { name: 'Charity', href: '/charity' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Labs', href: '/labs' },
{ name: 'Charity', href: '/charity' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Send M$', href: '/links' }, { name: 'Help & About', href: 'https://help.manifold.markets/' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, {
{ name: 'Dating docs', href: '/date-docs' }, name: 'Sign out',
{ name: 'Help & About', href: 'https://help.manifold.markets/' }, href: '#',
{ onClick: logout,
name: 'Sign out', }
href: '#',
onClick: logout,
},
]
) )
} }
@ -220,15 +213,11 @@ function getMoreMobileNav() {
if (IS_PRIVATE_MANIFOLD) return [signOut] if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<MenuItem>( return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, { name: 'Groups', href: '/groups' },
[ { name: 'Referrals', href: '/referrals' },
{ name: 'Groups', href: '/groups' }, { name: 'Charity', href: '/charity' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Labs', href: '/labs' },
{ name: 'Charity', href: '/charity' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Dating docs', href: '/date-docs' },
],
signOut signOut
) )
} }

View File

@ -10,6 +10,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
CurrencyDollarIcon, CurrencyDollarIcon,
ExclamationIcon,
InboxInIcon, InboxInIcon,
InformationCircleIcon, InformationCircleIcon,
LightBulbIcon, LightBulbIcon,
@ -62,8 +63,9 @@ export function NotificationSettings(props: {
'tagged_user', // missing tagged on contract description email 'tagged_user', // missing tagged on contract description email
'contract_from_followed_user', 'contract_from_followed_user',
'unique_bettors_on_your_contract', 'unique_bettors_on_your_contract',
'profit_loss_updates',
'opt_out_all',
// TODO: add these // TODO: add these
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets // biggest winner, here are the rest of your markets
// 'referral_bonuses', // 'referral_bonuses',
@ -116,7 +118,7 @@ export function NotificationSettings(props: {
const yourMarkets: SectionData = { const yourMarkets: SectionData = {
label: 'Markets You Created', label: 'Markets You Created',
subscriptionTypes: [ subscriptionTypes: [
'your_contract_closed', // 'your_contract_closed',
'all_comments_on_my_markets', 'all_comments_on_my_markets',
'all_answers_on_my_markets', 'all_answers_on_my_markets',
'subsidized_your_market', 'subsidized_your_market',
@ -153,23 +155,60 @@ export function NotificationSettings(props: {
'trending_markets', 'trending_markets',
'thank_you_for_purchases', 'thank_you_for_purchases',
'onboarding_flow', 'onboarding_flow',
'profit_loss_updates',
], ],
} }
const optOut: SectionData = {
label: 'Opt Out',
subscriptionTypes: ['opt_out_all'],
}
function NotificationSettingLine(props: { function NotificationSettingLine(props: {
description: string description: string
subscriptionTypeKey: notification_preference subscriptionTypeKey: notification_preference
destinations: notification_destination_types[] destinations: notification_destination_types[]
optOutAll: notification_destination_types[]
}) { }) {
const { description, subscriptionTypeKey, destinations } = props const { description, subscriptionTypeKey, destinations, optOutAll } = props
const previousInAppValue = destinations.includes('browser') const previousInAppValue = destinations.includes('browser')
const previousEmailValue = destinations.includes('email') const previousEmailValue = destinations.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const [error, setError] = useState<string>('')
const loading = 'Changing Notifications Settings' const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!' const success = 'Changed Notification Settings!'
const highlight = navigateToSection === subscriptionTypeKey const highlight = navigateToSection === subscriptionTypeKey
const attemptToChangeSetting = (
setting: 'browser' | 'email',
newValue: boolean
) => {
const necessaryError =
'This notification type is necessary. At least one destination must be enabled.'
const necessarySetting =
NOTIFICATION_DESCRIPTIONS[subscriptionTypeKey].necessary
if (
necessarySetting &&
setting === 'browser' &&
!emailEnabled &&
!newValue
) {
setError(necessaryError)
return
} else if (
necessarySetting &&
setting === 'email' &&
!inAppEnabled &&
!newValue
) {
setError(necessaryError)
return
}
changeSetting(setting, newValue)
}
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => { const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
toast toast
.promise( .promise(
@ -211,18 +250,21 @@ export function NotificationSettings(props: {
{!browserDisabled.includes(subscriptionTypeKey) && ( {!browserDisabled.includes(subscriptionTypeKey) && (
<SwitchSetting <SwitchSetting
checked={inAppEnabled} checked={inAppEnabled}
onChange={(newVal) => changeSetting('browser', newVal)} onChange={(newVal) => attemptToChangeSetting('browser', newVal)}
label={'Web'} label={'Web'}
disabled={optOutAll.includes('browser')}
/> />
)} )}
{emailsEnabled.includes(subscriptionTypeKey) && ( {emailsEnabled.includes(subscriptionTypeKey) && (
<SwitchSetting <SwitchSetting
checked={emailEnabled} checked={emailEnabled}
onChange={(newVal) => changeSetting('email', newVal)} onChange={(newVal) => attemptToChangeSetting('email', newVal)}
label={'Email'} label={'Email'}
disabled={optOutAll.includes('email')}
/> />
)} )}
</Row> </Row>
{error && <span className={'text-error'}>{error}</span>}
</Col> </Col>
</Row> </Row>
) )
@ -282,6 +324,11 @@ export function NotificationSettings(props: {
subType as notification_preference subType as notification_preference
)} )}
description={NOTIFICATION_DESCRIPTIONS[subType].simple} description={NOTIFICATION_DESCRIPTIONS[subType].simple}
optOutAll={
subType === 'opt_out_all' || subType === 'your_contract_closed'
? []
: getUsersSavedPreference('opt_out_all')
}
/> />
))} ))}
</Col> </Col>
@ -331,6 +378,10 @@ export function NotificationSettings(props: {
icon={<InboxInIcon className={'h-6 w-6'} />} icon={<InboxInIcon className={'h-6 w-6'} />}
data={generalOther} data={generalOther}
/> />
<Section
icon={<ExclamationIcon className={'h-6 w-6'} />}
data={optOut}
/>
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} /> <WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col> </Col>
</div> </div>

View File

@ -128,8 +128,10 @@ export function NumericResolutionPanel(props: {
<ResolveConfirmationButton <ResolveConfirmationButton
onResolve={resolve} onResolve={resolve}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={clsx('w-full mt-2', submitButtonClass)} openModalButtonClass={clsx('w-full mt-2')}
submitButtonClass={submitButtonClass} submitButtonClass={submitButtonClass}
color={outcomeMode === 'CANCEL' ? 'yellow' : 'indigo'}
disabled={outcomeMode === undefined}
/> />
</Col> </Col>
) )

View File

@ -0,0 +1,164 @@
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useState } from 'react'
import { PostCardList } from 'web/pages/group/[...slugs]'
import { Button } from './button'
import { PillButton } from './buttons/pill-button'
import { ContractSearch } from './contract-search'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function PinnedSelectModal(props: {
title: string
description?: React.ReactNode
open: boolean
setOpen: (open: boolean) => void
onSubmit: (
selectedItems: { itemId: string; type: string }[]
) => void | Promise<void>
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
group: Group
posts: Post[]
}) {
const {
title,
description,
open,
setOpen,
onSubmit,
contractSearchOptions,
posts,
group,
} = props
const [selectedItem, setSelectedItem] = useState<{
itemId: string
type: string
} | null>(null)
const [loading, setLoading] = useState(false)
const [selectedTab, setSelectedTab] = useState<'contracts' | 'posts'>('posts')
async function selectContract(contract: Contract) {
selectItem(contract.id, 'contract')
}
async function selectPost(post: Post) {
selectItem(post.id, 'post')
}
async function selectItem(itemId: string, type: string) {
setSelectedItem({ itemId: itemId, type: type })
}
async function onFinish() {
setLoading(true)
if (selectedItem) {
await onSubmit([
{
itemId: selectedItem.itemId,
type: selectedItem.type,
},
])
setLoading(false)
setOpen(false)
setSelectedItem(null)
}
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<div className="p-8 pb-0">
<Row>
<div className={'text-xl text-indigo-700'}>{title}</div>
{!loading && (
<Row className="grow justify-end gap-4">
{selectedItem && (
<Button onClick={onFinish} color="indigo">
Add to Pinned
</Button>
)}
<Button
onClick={() => {
setSelectedItem(null)
setOpen(false)
}}
color="gray"
>
Cancel
</Button>
</Row>
)}
</Row>
{description}
</div>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div>
<Row className="justify-center gap-4">
<PillButton
onSelect={() => setSelectedTab('contracts')}
selected={selectedTab === 'contracts'}
>
Contracts
</PillButton>
<PillButton
onSelect={() => setSelectedTab('posts')}
selected={selectedTab === 'posts'}
>
Posts
</PillButton>
</Row>
</div>
{selectedTab === 'contracts' ? (
<div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={selectContract}
cardUIOptions={{
hideGroupLink: true,
hideQuickBet: true,
noLinkAvatar: true,
}}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
</div>
) : (
<>
<div className="mt-2 px-2">
<PostCardList
posts={posts}
onPostClick={selectPost}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
/>
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</>
)}
</Col>
</Modal>
)
}

View File

@ -3,7 +3,7 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money trading" title="Play-money trading"
className="mt-4 max-w-md" className="mt-4"
text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/> />
) )

View File

@ -105,7 +105,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
sliceTooltip={({ slice }) => { sliceTooltip={({ slice }) => {
handleGraphDisplayChange(slice.points[0].data.yFormatted) handleGraphDisplayChange(slice.points[0].data.yFormatted)
return ( return (
<div className="rounded bg-white px-4 py-2 opacity-80"> <div className="rounded border border-gray-200 bg-white px-4 py-2 opacity-80">
<div <div
key={slice.points[0].id} key={slice.points[0].id}
className="text-xs font-semibold sm:text-sm" className="text-xs font-semibold sm:text-sm"

View File

@ -4,7 +4,6 @@ import { last } from 'lodash'
import { memo, useRef, useState } from 'react' import { memo, useRef, useState } from 'react'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users' import { Period } from 'web/lib/firebase/users'
import { PillButton } from '../buttons/pill-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { PortfolioValueGraph } from './portfolio-value-graph' import { PortfolioValueGraph } from './portfolio-value-graph'
@ -147,34 +146,3 @@ export function PortfolioPeriodSelection(props: {
</Row> </Row>
) )
} }
export function GraphToggle(props: {
setGraphMode: (mode: 'profit' | 'value') => void
graphMode: string
}) {
const { setGraphMode, graphMode } = props
return (
<Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton
selected={graphMode === 'value'}
onSelect={() => {
setGraphMode('value')
}}
xs={true}
className="z-50"
>
Value
</PillButton>
<PillButton
selected={graphMode === 'profit'}
onSelect={() => {
setGraphMode('profit')
}}
xs={true}
className="z-50"
>
Profit
</PillButton>
</Row>
)
}

View File

@ -0,0 +1,82 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { Post } from 'common/post'
import Link from 'next/link'
import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar'
import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link'
export function PostCard(props: {
post: Post
onPostClick?: (post: Post) => void
highlightOptions?: CardHighlightOptions
}) {
const { post, onPostClick, highlightOptions } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
if (!user) return <> </>
return (
<div className="relative py-1">
<Row
className={clsx(
' relative gap-3 rounded-lg bg-white py-2 shadow-md hover:cursor-pointer hover:bg-gray-100',
itemIds?.includes(post.id) && highlightClassName
)}
>
<div className="flex-shrink-0">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className="">
<div className="text-sm text-gray-500">
<UserLink
className="text-neutral"
name={user?.name}
username={user?.username}
/>
<span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div>
<div className="text-lg font-medium text-gray-900">{post.title}</div>
</div>
</Row>
{onPostClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
onPostClick(post)
}}
/>
) : (
<Link href={postPath(post.slug)}>
<a
onClick={() => {
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
}}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</div>
)
}

View File

@ -8,12 +8,12 @@ export function ProbabilitySelector(props: {
const { probabilityInt, setProbabilityInt, isSubmitting } = props const { probabilityInt, setProbabilityInt, isSubmitting } = props
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<label className="input-group input-group-lg w-fit text-lg"> <label className="input-group input-group-lg text-lg">
<input <input
type="number" type="number"
value={probabilityInt} value={probabilityInt}
className="input input-bordered input-md text-lg" className="input input-bordered input-md w-28 text-lg"
disabled={isSubmitting} disabled={isSubmitting}
min={1} min={1}
max={99} max={99}
@ -23,14 +23,6 @@ export function ProbabilitySelector(props: {
/> />
<span>%</span> <span>%</span>
</label> </label>
<input
type="range"
className="range range-primary"
min={1}
max={99}
value={probabilityInt}
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
/>
</Row> </Row>
) )
} }

View File

@ -11,6 +11,8 @@ import { ProbabilitySelector } from './probability-selector'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract' import { BinaryContract, resolution } from 'common/contract'
import { BETTOR, BETTORS, PAST_BETS } from 'common/user' import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
import { Row } from 'web/components/layout/row'
import { capitalize } from 'lodash'
export function ResolutionPanel(props: { export function ResolutionPanel(props: {
isAdmin: boolean isAdmin: boolean
@ -57,17 +59,6 @@ export function ResolutionPanel(props: {
setIsSubmitting(false) setIsSubmitting(false)
} }
const submitButtonClass =
outcome === 'YES'
? 'btn-primary'
: outcome === 'NO'
? 'bg-red-400 hover:bg-red-500'
: outcome === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: outcome === 'MKT'
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return ( return (
<Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}> <Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}>
{isAdmin && !isCreator && ( {isAdmin && !isCreator && (
@ -76,18 +67,14 @@ export function ResolutionPanel(props: {
</span> </span>
)} )}
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
<div className="mb-3 text-sm text-gray-500">Outcome</div> <div className="mb-3 text-sm text-gray-500">Outcome</div>
<YesNoCancelSelector <YesNoCancelSelector
className="mx-auto my-2" className="mx-auto my-2"
selected={outcome} selected={outcome}
onSelect={setOutcome} onSelect={setOutcome}
btnClassName={isSubmitting ? 'btn-disabled' : ''} btnClassName={isSubmitting ? 'btn-disabled' : ''}
/> />
<Spacer h={4} /> <Spacer h={4} />
<div> <div>
{outcome === 'YES' ? ( {outcome === 'YES' ? (
<> <>
@ -109,9 +96,10 @@ export function ResolutionPanel(props: {
withdrawn from your account withdrawn from your account
</> </>
) : outcome === 'MKT' ? ( ) : outcome === 'MKT' ? (
<Col className="gap-6"> <Col className="items-center gap-6">
<div> <div>
{PAST_BETS} will be paid out at the probability you specify: {capitalize(PAST_BETS)} will be paid out at the probability you
specify:
</div> </div>
<ProbabilitySelector <ProbabilitySelector
probabilityInt={Math.round(prob)} probabilityInt={Math.round(prob)}
@ -123,17 +111,26 @@ export function ResolutionPanel(props: {
<>Resolving this market will immediately pay out {BETTORS}.</> <>Resolving this market will immediately pay out {BETTORS}.</>
)} )}
</div> </div>
<Spacer h={4} /> <Spacer h={4} />
{!!error && <div className="text-red-500">{error}</div>} {!!error && <div className="text-red-500">{error}</div>}
<Row className={'justify-center'}>
<ResolveConfirmationButton <ResolveConfirmationButton
onResolve={resolve} color={
isSubmitting={isSubmitting} outcome === 'YES'
openModalButtonClass={clsx('w-full mt-2', submitButtonClass)} ? 'green'
submitButtonClass={submitButtonClass} : outcome === 'NO'
/> ? 'red'
: outcome === 'CANCEL'
? 'yellow'
: outcome === 'MKT'
? 'blue'
: 'indigo'
}
disabled={!outcome}
onResolve={resolve}
isSubmitting={isSubmitting}
/>
</Row>
</Col> </Col>
) )
} }

View File

@ -0,0 +1,47 @@
import { ArrowUpIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Row } from './layout/row'
export function ScrollToTopButton(props: { className?: string }) {
const { className } = props
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => {
if (window.scrollY > 500) {
setVisible(true)
} else {
setVisible(false)
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [])
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
return (
<button
className={clsx(
'border-greyscale-2 bg-greyscale-1 hover:bg-greyscale-2 rounded-full border py-2 pr-3 pl-2 text-sm transition-colors',
visible ? 'inline' : 'hidden',
className
)}
onClick={scrollToTop}
>
<Row className="text-greyscale-6 gap-2 align-middle">
<ArrowUpIcon className="text-greyscale-4 h-5 w-5" />
Scroll to top
</Row>
</button>
)
}

View File

@ -0,0 +1,35 @@
import { ReactNode, useEffect, useRef, useState } from 'react'
export const SizedContainer = (props: {
fullHeight: number
mobileHeight: number
mobileThreshold?: number
children: (width: number, height: number) => ReactNode
}) => {
const { children, fullHeight, mobileHeight } = props
const threshold = props.mobileThreshold ?? 800
const containerRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState<number>()
const [height, setHeight] = useState<number>()
useEffect(() => {
if (containerRef.current) {
const handleResize = () => {
setHeight(window.innerWidth <= threshold ? mobileHeight : fullHeight)
setWidth(containerRef.current?.clientWidth)
}
handleResize()
const resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(containerRef.current)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
resizeObserver.disconnect()
}
}
}, [threshold, fullHeight, mobileHeight])
return (
<div ref={containerRef}>
{width != null && height != null && children(width, height)}
</div>
)
}

View File

@ -1,33 +1,52 @@
import { Switch } from '@headlessui/react' import { Switch } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import React from 'react' import React from 'react'
import { Tooltip } from 'web/components/tooltip'
export const SwitchSetting = (props: { export const SwitchSetting = (props: {
checked: boolean checked: boolean
onChange: (checked: boolean) => void onChange: (checked: boolean) => void
label: string label: string
disabled: boolean
}) => { }) => {
const { checked, onChange, label } = props const { checked, onChange, label, disabled } = props
return ( return (
<Switch.Group as="div" className="flex items-center"> <Switch.Group as="div" className="flex items-center">
<Switch <Tooltip
checked={checked} text={
onChange={onChange} disabled
className={clsx( ? `You are opted out of all ${label} notifications. Go to the Opt Out section to undo this setting.`
checked ? 'bg-indigo-600' : 'bg-gray-200', : ''
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2' }
)}
> >
<span <Switch
aria-hidden="true" checked={checked}
onChange={onChange}
className={clsx( className={clsx(
checked ? 'translate-x-5' : 'translate-x-0', checked ? 'bg-indigo-600' : 'bg-gray-200',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out' 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
disabled ? 'cursor-not-allowed opacity-50' : ''
)} )}
/> disabled={disabled}
</Switch> >
<span
aria-hidden="true"
className={clsx(
checked ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
)}
/>
</Switch>
</Tooltip>
<Switch.Label as="span" className="ml-3"> <Switch.Label as="span" className="ml-3">
<span className="text-sm font-medium text-gray-900">{label}</span> <span
className={clsx(
'text-sm font-medium text-gray-900',
disabled ? 'cursor-not-allowed opacity-50' : ''
)}
>
{label}
</span>
</Switch.Label> </Switch.Label>
</Switch.Group> </Switch.Group>
) )

View File

@ -1,22 +1,17 @@
import { import { useEffect, useRef, useState } from 'react'
ChevronDoubleRightIcon, import toast from 'react-hot-toast'
ChevronLeftIcon, import { debounce, sum } from 'lodash'
ChevronRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { formatMoney } from 'common/util/format'
import { debounce, sum } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { CommentTips } from 'web/hooks/use-tip-txns' import { CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/api' import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { TipButton } from './contract/tip-button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Tooltip } from './tooltip' import { LIKE_TIP_AMOUNT } from 'common/like'
import { formatMoney } from 'common/util/format'
const TIP_SIZE = 10
export function Tipper(prop: { comment: Comment; tips: CommentTips }) { export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop const { comment, tips } = prop
@ -26,6 +21,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const savedTip = tips[myId] ?? 0 const savedTip = tips[myId] ?? 0
const [localTip, setLocalTip] = useState(savedTip) const [localTip, setLocalTip] = useState(savedTip)
// listen for user being set // listen for user being set
const initialized = useRef(false) const initialized = useRef(false)
useEffect(() => { useEffect(() => {
@ -78,71 +74,22 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const addTip = (delta: number) => { const addTip = (delta: number) => {
setLocalTip(localTip + delta) setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta) me && saveTip(me, comment, localTip - savedTip + delta)
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
const canDown = me && localTip > savedTip const canUp =
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5 me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT
return ( return (
<Row className="items-center gap-0.5"> <Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} /> <TipButton
<span className="font-bold">{Math.floor(total)}</span> tipAmount={LIKE_TIP_AMOUNT}
<UpTip totalTipped={total}
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined} onClick={() => addTip(+LIKE_TIP_AMOUNT)}
value={localTip} userTipped={localTip > 0}
disabled={!canUp}
isCompact
/> />
{localTip === 0 ? (
''
) : (
<span
className={clsx(
'ml-1 font-semibold',
localTip > 0 ? 'text-primary' : 'text-red-400'
)}
>
({formatMoney(localTip)} tip)
</span>
)}
</Row> </Row>
) )
} }
function DownTip(props: { onClick?: () => void }) {
const { onClick } = props
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `-${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-red-600 disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
</Tooltip>
)
}
function UpTip(props: { onClick?: () => void; value: number }) {
const { onClick, value } = props
const IconKind = value > TIP_SIZE ? ChevronDoubleRightIcon : ChevronRightIcon
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-primary disabled:text-gray-300"
disabled={!onClick}
onClick={onClick}
>
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
</button>
</Tooltip>
)
}

View File

@ -8,13 +8,14 @@ import {
PencilIcon, PencilIcon,
ScaleIcon, ScaleIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { CreatorContractsList } from './contract/contracts-grid' import { CreatorContractsList } from './contract/contracts-grid'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
import { SiteLink } from './site-link' import { linkClass, SiteLink } from './site-link'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Linkify } from './linkify' import { Linkify } from './linkify'
@ -35,6 +36,9 @@ import {
hasCompletedStreakToday, hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal' } from 'web/components/profile/betting-streak-modal'
import { LoansModal } from './profile/loans-modal' import { LoansModal } from './profile/loans-modal'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { DOMAIN } from 'common/envs/constants'
export function UserPage(props: { user: User }) { export function UserPage(props: { user: User }) {
const { user } = props const { user } = props
@ -63,6 +67,7 @@ export function UserPage(props: { user: User }) {
}, []) }, [])
const profit = user.profitCached.allTime const profit = user.profitCached.allTime
const referralUrl = `https://${DOMAIN}?referrer=${user?.username}`
return ( return (
<Page key={user.id}> <Page key={user.id}>
@ -184,6 +189,28 @@ export function UserPage(props: { user: User }) {
</Row> </Row>
</SiteLink> </SiteLink>
)} )}
{isCurrentUser && (
<div
className={clsx(
linkClass,
'text-greyscale-4 cursor-pointer text-sm'
)}
onClick={(e) => {
e.preventDefault()
copyToClipboard(referralUrl)
toast.success('Referral link copied!', {
icon: <LinkIcon className="h-6 w-6" aria-hidden="true" />,
})
track('copy referral link')
}}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
Earn M$250 per referral
</Row>
</div>
)}
</Row> </Row>
)} )}
<QueryUncontrolledTabs <QueryUncontrolledTabs
@ -192,7 +219,7 @@ export function UserPage(props: { user: User }) {
tabs={[ tabs={[
{ {
title: 'Markets', title: 'Markets',
tabIcon: <ScaleIcon className="h-5" />, stackedTabIcon: <ScaleIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -202,7 +229,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Portfolio', title: 'Portfolio',
tabIcon: <FolderIcon className="h-5" />, stackedTabIcon: <FolderIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -214,7 +241,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Comments', title: 'Comments',
tabIcon: <ChatIcon className="h-5" />, stackedTabIcon: <ChatIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -5,17 +5,18 @@ import { Row } from './layout/row'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { ExclamationIcon } from '@heroicons/react/solid' import { ExclamationIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Button, ColorType, SizeType } from './button'
export function WarningConfirmationButton(props: { export function WarningConfirmationButton(props: {
amount: number | undefined amount: number | undefined
outcome?: 'YES' | 'NO' | undefined
marketType: 'freeResponse' | 'binary' marketType: 'freeResponse' | 'binary'
warning?: string warning?: string
onSubmit: () => void onSubmit: () => void
disabled?: boolean disabled: boolean
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClassName?: string color: ColorType
size: SizeType
}) { }) {
const { const {
amount, amount,
@ -24,53 +25,43 @@ export function WarningConfirmationButton(props: {
disabled, disabled,
isSubmitting, isSubmitting,
openModalButtonClass, openModalButtonClass,
submitButtonClassName, size,
outcome, color,
marketType,
} = props } = props
if (!warning) { if (!warning) {
return ( return (
<button <Button
className={clsx( size={size}
openModalButtonClass, disabled={isSubmitting || disabled}
isSubmitting ? 'loading btn-disabled' : '', className={clsx(openModalButtonClass)}
disabled && 'btn-disabled',
marketType === 'binary'
? !outcome
? 'btn-disabled bg-greyscale-2'
: ''
: ''
)}
onClick={onSubmit} onClick={onSubmit}
color={color}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
: amount : amount
? `Wager ${formatMoney(amount)}` ? `Wager ${formatMoney(amount)}`
: 'Wager'} : 'Wager'}
</button> </Button>
) )
} }
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx(
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: amount ? `Wager ${formatMoney(amount)}` : 'Wager', label: amount ? `Wager ${formatMoney(amount)}` : 'Wager',
size: size,
color: 'yellow',
disabled: isSubmitting,
}} }}
cancelBtn={{ cancelBtn={{
label: 'Cancel', label: 'Cancel',
className: 'btn-warning', className: 'btn btn-warning',
}} }}
submitBtn={{ submitBtn={{
label: 'Submit', label: 'Submit',
className: clsx( className: clsx('btn border-none btn-sm btn-ghost self-center'),
'border-none btn-sm btn-ghost self-center',
submitButtonClassName
),
}} }}
onSubmit={onSubmit} onSubmit={onSubmit}
> >

View File

@ -3,22 +3,27 @@ import { Switch } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
export default function ShortToggle(props: { export default function ShortToggle(props: {
enabled: boolean on: boolean
setEnabled: (enabled: boolean) => void setOn: (enabled: boolean) => void
disabled?: boolean
onChange?: (enabled: boolean) => void onChange?: (enabled: boolean) => void
}) { }) {
const { enabled, setEnabled } = props const { on, setOn, disabled } = props
return ( return (
<Switch <Switch
checked={enabled} disabled={disabled}
checked={on}
onChange={(e: boolean) => { onChange={(e: boolean) => {
setEnabled(e) setOn(e)
if (props.onChange) { if (props.onChange) {
props.onChange(e) props.onChange(e)
} }
}} }}
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" className={clsx(
'group relative inline-flex h-5 w-10 flex-shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
!disabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
)}
> >
<span className="sr-only">Use setting</span> <span className="sr-only">Use setting</span>
<span <span
@ -28,14 +33,14 @@ export default function ShortToggle(props: {
<span <span
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
enabled ? 'bg-indigo-600' : 'bg-gray-200', on ? 'bg-indigo-600' : 'bg-gray-200',
'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out' 'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out'
)} )}
/> />
<span <span
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
enabled ? 'translate-x-5' : 'translate-x-0', on ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out' 'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out'
)} )}
/> />

View File

@ -213,7 +213,7 @@ export function NumberCancelSelector(props: {
return ( return (
<Col className={clsx('gap-2', className)}> <Col className={clsx('gap-2', className)}>
<Button <Button
color={selected === 'NUMBER' ? 'green' : 'gray'} color={selected === 'NUMBER' ? 'indigo' : 'gray'}
onClick={() => onSelect('NUMBER')} onClick={() => onSelect('NUMBER')}
className={clsx('whitespace-nowrap', btnClassName)} className={clsx('whitespace-nowrap', btnClassName)}
> >
@ -244,7 +244,7 @@ function Button(props: {
type="button" type="button"
className={clsx( className={clsx(
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm', 'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
color === 'green' && 'btn-primary text-white', color === 'green' && 'bg-teal-500 bg-teal-600 text-white',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',

View File

@ -8,12 +8,14 @@ import {
getUserBetContracts, getUserBetContracts,
getUserBetContractsQuery, getUserBetContractsQuery,
listAllContracts, listAllContracts,
trendingContractsQuery,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { QueryClient, useQuery, useQueryClient } from 'react-query' import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS, sleep } from 'common/util/time' import { MINUTE_MS, sleep } from 'common/util/time'
import { query, limit } from 'firebase/firestore' import {
import { dailyScoreIndex } from 'web/lib/service/algolia' dailyScoreIndex,
newIndex,
trendingIndex,
} from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash' import { zipObject } from 'lodash'
@ -27,16 +29,50 @@ export const useContracts = () => {
return contracts return contracts
} }
export const useTrendingContracts = (maxContracts: number) => {
const { data } = useQuery(['trending-contracts', maxContracts], () =>
trendingIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false'],
hitsPerPage: maxContracts,
})
)
if (!data) return undefined
return data.hits
}
export const useNewContracts = (maxContracts: number) => {
const { data } = useQuery(['newest-contracts', maxContracts], () =>
newIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false'],
hitsPerPage: maxContracts,
})
)
if (!data) return undefined
return data.hits
}
export const useContractsByDailyScoreNotBetOn = (
userId: string | null | undefined,
maxContracts: number
) => {
const { data } = useQuery(['daily-score', userId, maxContracts], () =>
dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false', `uniqueBettors:-${userId}`],
hitsPerPage: maxContracts,
})
)
if (!userId || !data) return undefined
return data.hits.filter((c) => c.dailyScore)
}
export const useContractsByDailyScoreGroups = ( export const useContractsByDailyScoreGroups = (
groupSlugs: string[] | undefined groupSlugs: string[] | undefined
) => { ) => {
const facetFilters = ['isResolved:false']
const { data } = useQuery(['daily-score', groupSlugs], () => const { data } = useQuery(['daily-score', groupSlugs], () =>
Promise.all( Promise.all(
(groupSlugs ?? []).map((slug) => (groupSlugs ?? []).map((slug) =>
dailyScoreIndex.search<CPMMBinaryContract>('', { dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: [...facetFilters, `groupLinks.slug:${slug}`], facetFilters: ['isResolved:false', `groupLinks.slug:${slug}`],
}) })
) )
) )
@ -56,14 +92,6 @@ export const getCachedContracts = async () =>
staleTime: Infinity, staleTime: Infinity,
}) })
export const useTrendingContracts = (maxContracts: number) => {
const result = useFirestoreQueryData(
['trending-contracts', maxContracts],
query(trendingContractsQuery, limit(maxContracts))
)
return result.data
}
export const useInactiveContracts = () => { export const useInactiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>() const [contracts, setContracts] = useState<Contract[] | undefined>()

View File

@ -1,17 +0,0 @@
import { RefObject, useState, useEffect } from 'react'
// todo: consider consolidation with use-measure-size
export const useElementWidth = <T extends Element>(ref: RefObject<T>) => {
const [width, setWidth] = useState<number>()
useEffect(() => {
const handleResize = () => {
setWidth(ref.current?.clientWidth)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [ref])
return width
}

View File

@ -1,5 +1,5 @@
import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react' import { useEffect } from 'react'
import { track } from 'web/lib/service/analytics'
import { inIframe } from './use-is-iframe' import { inIframe } from './use-is-iframe'
export const useTracking = ( export const useTracking = (
@ -10,5 +10,5 @@ export const useTracking = (
useEffect(() => { useEffect(() => {
if (excludeIframe && inIframe()) return if (excludeIframe && inIframe()) return
track(eventName, eventProperties) track(eventName, eventProperties)
}, []) }, [eventName, eventProperties, excludeIframe])
} }

View File

@ -46,6 +46,14 @@ export function addLiquidity(params: any) {
return call(getFunctionUrl('addliquidity'), 'POST', params) return call(getFunctionUrl('addliquidity'), 'POST', params)
} }
export function addCommentBounty(params: any) {
return call(getFunctionUrl('addcommentbounty'), 'POST', params)
}
export function awardCommentBounty(params: any) {
return call(getFunctionUrl('awardcommentbounty'), 'POST', params)
}
export function withdrawLiquidity(params: any) { export function withdrawLiquidity(params: any) {
return call(getFunctionUrl('withdrawliquidity'), 'POST', params) return call(getFunctionUrl('withdrawliquidity'), 'POST', params)
} }

View File

@ -35,6 +35,7 @@ export async function createCommentOnContract(
contractId: string, contractId: string,
content: JSONContent, content: JSONContent,
user: User, user: User,
onContractWithBounty: boolean,
answerOutcome?: string, answerOutcome?: string,
replyToCommentId?: string replyToCommentId?: string
) { ) {
@ -50,7 +51,8 @@ export async function createCommentOnContract(
content, content,
user, user,
ref, ref,
replyToCommentId replyToCommentId,
onContractWithBounty
) )
} }
export async function createCommentOnGroup( export async function createCommentOnGroup(
@ -95,7 +97,8 @@ async function createComment(
content: JSONContent, content: JSONContent,
user: User, user: User,
ref: DocumentReference<DocumentData>, ref: DocumentReference<DocumentData>,
replyToCommentId?: string replyToCommentId?: string,
onContractWithBounty?: boolean
) { ) {
const comment = removeUndefinedProps({ const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
@ -108,13 +111,19 @@ async function createComment(
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
...extraFields, ...extraFields,
}) })
track(
track(`${extraFields.commentType} message`, { `${extraFields.commentType} message`,
user, removeUndefinedProps({
commentId: ref.id, user,
surfaceId, commentId: ref.id,
replyToCommentId: replyToCommentId, surfaceId,
}) replyToCommentId: replyToCommentId,
onContractWithBounty:
extraFields.commentType === 'contract'
? onContractWithBounty
: undefined,
})
)
return await setDoc(ref, comment) return await setDoc(ref, comment)
} }

View File

@ -14,6 +14,8 @@ export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}` return `${indexPrefix}contracts-${sort}`
} }
export const trendingIndex = searchClient.initIndex(getIndexName('score'))
export const newIndex = searchClient.initIndex(getIndexName('newest'))
export const probChangeDescendingIndex = searchClient.initIndex( export const probChangeDescendingIndex = searchClient.initIndex(
getIndexName('prob-change-day') getIndexName('prob-change-day')
) )

View File

@ -22,7 +22,7 @@
"@amplitude/analytics-browser": "0.4.1", "@amplitude/analytics-browser": "0.4.1",
"@floating-ui/react-dom-interactions": "0.9.2", "@floating-ui/react-dom-interactions": "0.9.2",
"@headlessui/react": "1.6.1", "@headlessui/react": "1.6.1",
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.6",
"@nivo/core": "0.80.0", "@nivo/core": "0.80.0",
"@nivo/line": "0.80.0", "@nivo/line": "0.80.0",
"@nivo/tooltip": "0.80.0", "@nivo/tooltip": "0.80.0",

View File

@ -42,12 +42,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
import BetButton from 'web/components/bet-button'
import { BetsSummary } from 'web/components/bet-summary' import { BetsSummary } from 'web/components/bet-summary'
import { listAllComments } from 'web/lib/firebase/comments' import { listAllComments } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { ScrollToTopButton } from 'web/components/scroll-to-top-button'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -162,6 +160,7 @@ export function ContractPageContent(
const { backToHome, comments } = props const { backToHome, comments } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const user = useUser() const user = useUser()
const isCreator = user?.id === contract.creatorId
usePrefetch(user?.id) usePrefetch(user?.id)
useTracking( useTracking(
'view market', 'view market',
@ -206,11 +205,21 @@ export function ContractPageContent(
}) })
return ( return (
<Page rightSidebar={<ContractPageSidebar contract={contract} />}> <Page
rightSidebar={
<>
<ContractPageSidebar contract={contract} />
{isCreator && (
<Col className={'xl:hidden'}>
<RecommendedContractsWidget contract={contract} />
</Col>
)}
</>
}
>
{showConfetti && ( {showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} /> <FullscreenConfetti recycle={false} numberOfPieces={300} />
)} )}
{ogCardProps && ( {ogCardProps && (
<SEO <SEO
title={question} title={question}
@ -219,7 +228,6 @@ export function ContractPageContent(
ogCardProps={ogCardProps} ogCardProps={ogCardProps}
/> />
)} )}
<Col className="w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8"> <Col className="w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8">
{backToHome && ( {backToHome && (
<button <button
@ -276,23 +284,9 @@ export function ContractPageContent(
userBets={userBets} userBets={userBets}
comments={comments} comments={comments}
/> />
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
allowTrade && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</Col> </Col>
<RecommendedContractsWidget contract={contract} /> {!isCreator && <RecommendedContractsWidget contract={contract} />}
<ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" />
</Page> </Page>
) )
} }
@ -312,7 +306,7 @@ const RecommendedContractsWidget = memo(
return null return null
} }
return ( return (
<Col className="mt-2 gap-2 px-2 sm:px-0"> <Col className="mt-2 gap-2 px-2 sm:px-1">
<Title className="text-gray-700" text="Recommended" /> <Title className="text-gray-700" text="Recommended" />
<ContractsGrid <ContractsGrid
contracts={recommendations} contracts={recommendations}

View File

@ -4,6 +4,7 @@ import { listAllComments } from 'web/lib/firebase/comments'
import { getContractFromId } from 'web/lib/firebase/contracts' import { getContractFromId } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { FullMarket, ApiError, toFullMarket } from '../../_types' import { FullMarket, ApiError, toFullMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -24,6 +25,6 @@ export default async function handler(
return return
} }
res.setHeader('Cache-Control', 'max-age=0') res.setHeader('Cache-Control', marketCacheStrategy)
return res.status(200).json(toFullMarket(contract, comments, bets)) return res.status(200).json(toFullMarket(contract, comments, bets))
} }

View File

@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { getContractFromId } from 'web/lib/firebase/contracts' import { getContractFromId } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { ApiError, toLiteMarket, LiteMarket } from '../../_types' import { ApiError, toLiteMarket, LiteMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -18,6 +19,6 @@ export default async function handler(
return return
} }
res.setHeader('Cache-Control', 'max-age=0') res.setHeader('Cache-Control', marketCacheStrategy)
return res.status(200).json(toLiteMarket(contract)) return res.status(200).json(toLiteMarket(contract))
} }

View File

@ -6,6 +6,8 @@ import { toLiteMarket, ValidationError } from './_types'
import { z } from 'zod' import { z } from 'zod'
import { validate } from './_validate' import { validate } from './_validate'
export const marketCacheStrategy = 's-maxage=15, stale-while-revalidate=45'
const queryParams = z const queryParams = z
.object({ .object({
limit: z limit: z
@ -39,7 +41,7 @@ export default async function handler(
try { try {
const contracts = await listAllContracts(limit, before) const contracts = await listAllContracts(limit, before)
// Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') res.setHeader('Cache-Control', marketCacheStrategy)
res.status(200).json(contracts.map(toLiteMarket)) res.status(200).json(contracts.map(toLiteMarket))
} catch (e) { } catch (e) {
res.status(400).json({ res.status(400).json({

View File

@ -11,7 +11,7 @@ const App = () => {
url="/cowp" url="/cowp"
/> />
<Link href="https://www.youtube.com/watch?v=FavUpD_IjVY"> <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY">
<img src="https://i.imgur.com/Lt54IiU.png" /> <img src="https://i.imgur.com/Lt54IiU.png" className="cursor-pointer" />
</Link> </Link>
</Page> </Page>
) )

Some files were not shown because too many files have changed in this diff Show More