Merge branch 'main' into add-funds-modal
This commit is contained in:
commit
92ebf35cdc
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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§ion=${subscriptionType}`,
|
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,8 @@ export type User = {
|
||||||
allTime: number
|
allTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fractionResolvedCorrectly: number
|
||||||
|
|
||||||
nextLoanCached: number
|
nextLoanCached: number
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
|
|
|
@ -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('$', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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} {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
27
functions/src/scripts/add-new-notification-preference.ts
Normal file
27
functions/src/scripts/add-new-notification-preference.ts
Normal 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())
|
63
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
63
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal 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()
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
162
functions/src/update-comment-bounty.ts
Normal file
162
functions/src/update-comment-bounty.ts
Normal 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()
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
46
web/components/award-bounty-button.tsx
Normal file
46
web/components/award-bounty-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
76
web/components/charts/stats.tsx
Normal file
76
web/components/charts/stats.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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?: {
|
||||||
|
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
|
|
74
web/components/contract/add-comment-bounty.tsx
Normal file
74
web/components/contract/add-comment-bounty.tsx
Normal 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>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
47
web/components/contract/bountied-contract-badge.tsx
Normal file
47
web/components/contract/bountied-contract-badge.tsx
Normal 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.`
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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':
|
||||||
|
|
77
web/components/contract/contract-report-resolution.tsx
Normal file
77
web/components/contract/contract-report-resolution.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />,
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
61
web/components/contract/tip-button.tsx
Normal file
61
web/components/contract/tip-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
383
web/components/groups/group-overview.tsx
Normal file
383
web/components/groups/group-overview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
164
web/components/pinned-select-modal.tsx
Normal file
164
web/components/pinned-select-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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!"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
82
web/components/post-card.tsx
Normal file
82
web/components/post-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
47
web/components/scroll-to-top-button.tsx
Normal file
47
web/components/scroll-to-top-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
35
web/components/sized-container.tsx
Normal file
35
web/components/sized-container.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
)
|
)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user