Merge branch 'main' into new-home-3
This commit is contained in:
commit
52cd4e4078
|
@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) {
|
||||||
const { outcome, shares, amount } = bet
|
const { outcome, shares, amount } = bet
|
||||||
if (floatingEqual(shares, 0)) continue
|
if (floatingEqual(shares, 0)) continue
|
||||||
|
|
||||||
|
const spent = totalSpent[outcome] ?? 0
|
||||||
|
const position = totalShares[outcome] ?? 0
|
||||||
|
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
|
totalShares[outcome] = position + shares
|
||||||
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount
|
totalSpent[outcome] = spent + amount
|
||||||
} else if (amount < 0) {
|
} else if (amount < 0) {
|
||||||
const averagePrice = totalSpent[outcome] / totalShares[outcome]
|
const averagePrice = position === 0 ? 0 : spent / position
|
||||||
totalShares[outcome] = totalShares[outcome] + shares
|
totalShares[outcome] = position + shares
|
||||||
totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares
|
totalSpent[outcome] = spent + averagePrice * shares
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum(Object.values(totalSpent))
|
return sum([0, ...Object.values(totalSpent)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDpmInvested(yourBets: Bet[]) {
|
function getDpmInvested(yourBets: Bet[]) {
|
||||||
|
|
|
@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Before open sourcing, we should turn these into env vars
|
// TODO: Before open sourcing, we should turn these into env vars
|
||||||
export function isAdmin(email: string) {
|
export function isAdmin(email?: string) {
|
||||||
|
if (!email) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return ENV_CONFIG.adminEmails.includes(email)
|
return ENV_CONFIG.adminEmails.includes(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,9 @@ export type EnvConfig = {
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
moneyMoniker: string // e.g. 'M$'
|
moneyMoniker: string // e.g. 'M$'
|
||||||
|
bettor?: string // e.g. 'bettor' or 'predictor'
|
||||||
|
presentBet?: string // e.g. 'bet' or 'predict'
|
||||||
|
pastBet?: string // e.g. 'bet' or 'prediction'
|
||||||
faviconPath?: string // Should be a file in /public
|
faviconPath?: string // Should be a file in /public
|
||||||
navbarLogoPath?: string
|
navbarLogoPath?: string
|
||||||
newQuestionPlaceholders: string[]
|
newQuestionPlaceholders: string[]
|
||||||
|
@ -74,10 +77,14 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
'iansphilips@gmail.com', // Ian
|
'iansphilips@gmail.com', // Ian
|
||||||
'd4vidchee@gmail.com', // D4vid
|
'd4vidchee@gmail.com', // D4vid
|
||||||
'federicoruizcassarino@gmail.com', // Fede
|
'federicoruizcassarino@gmail.com', // Fede
|
||||||
|
'ingawei@gmail.com', //Inga
|
||||||
],
|
],
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
|
|
||||||
moneyMoniker: 'M$',
|
moneyMoniker: 'M$',
|
||||||
|
bettor: 'predictor',
|
||||||
|
pastBet: 'prediction',
|
||||||
|
presentBet: 'predict',
|
||||||
navbarLogoPath: '',
|
navbarLogoPath: '',
|
||||||
faviconPath: '/favicon.ico',
|
faviconPath: '/favicon.ico',
|
||||||
newQuestionPlaceholders: [
|
newQuestionPlaceholders: [
|
||||||
|
|
|
@ -2,3 +2,8 @@ export type Follow = {
|
||||||
userId: string
|
userId: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ContractFollow = {
|
||||||
|
id: string // user id
|
||||||
|
createdTime: number
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { notification_subscription_types, PrivateUser } from './user'
|
import { notification_preference } from './user-notification-preferences'
|
||||||
import { DOMAIN } from './envs/constants'
|
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -29,6 +28,7 @@ export type Notification = {
|
||||||
|
|
||||||
isSeenOnHref?: string
|
isSeenOnHref?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type notification_source_types =
|
export type notification_source_types =
|
||||||
| 'contract'
|
| 'contract'
|
||||||
| 'comment'
|
| 'comment'
|
||||||
|
@ -54,7 +54,7 @@ export type notification_source_update_types =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
|
|
||||||
/* Optional - if possible use a keyof notification_subscription_types */
|
/* Optional - if possible use a notification_preference */
|
||||||
export type notification_reason_types =
|
export type notification_reason_types =
|
||||||
| 'tagged_user'
|
| 'tagged_user'
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
|
@ -92,75 +92,167 @@ export type notification_reason_types =
|
||||||
| 'your_contract_closed'
|
| 'your_contract_closed'
|
||||||
| 'subsidized_your_market'
|
| 'subsidized_your_market'
|
||||||
|
|
||||||
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
type notification_descriptions = {
|
||||||
// You might want to add a key:value here if there will be multiple notification reasons that map to the same
|
[key in notification_preference]: {
|
||||||
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
|
simple: string
|
||||||
// 'all_comments_on_watched_markets' subscription type
|
detailed: string
|
||||||
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
|
}
|
||||||
export const notificationReasonToSubscriptionType: Partial<
|
|
||||||
Record<notification_reason_types, keyof notification_subscription_types>
|
|
||||||
> = {
|
|
||||||
you_referred_user: 'referral_bonuses',
|
|
||||||
user_joined_to_bet_on_your_market: 'referral_bonuses',
|
|
||||||
tip_received: 'tips_on_your_comments',
|
|
||||||
bet_fill: 'limit_order_fills',
|
|
||||||
user_joined_from_your_group_invite: 'referral_bonuses',
|
|
||||||
challenge_accepted: 'limit_order_fills',
|
|
||||||
betting_streak_incremented: 'betting_streaks',
|
|
||||||
liked_and_tipped_your_contract: 'tips_on_your_markets',
|
|
||||||
comment_on_your_contract: 'all_comments_on_my_markets',
|
|
||||||
answer_on_your_contract: 'all_answers_on_my_markets',
|
|
||||||
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
|
|
||||||
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
|
|
||||||
update_on_contract_you_follow: 'market_updates_on_watched_markets',
|
|
||||||
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
|
|
||||||
comment_on_contract_with_users_shares_in:
|
|
||||||
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
|
||||||
answer_on_contract_with_users_shares_in:
|
|
||||||
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
|
||||||
update_on_contract_with_users_shares_in:
|
|
||||||
'market_updates_on_watched_markets_with_shares_in',
|
|
||||||
resolution_on_contract_with_users_shares_in:
|
|
||||||
'resolutions_on_watched_markets_with_shares_in',
|
|
||||||
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
|
|
||||||
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
|
|
||||||
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
|
|
||||||
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
|
|
||||||
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
|
|
||||||
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
|
|
||||||
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
|
|
||||||
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
|
|
||||||
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
|
|
||||||
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
|
|
||||||
}
|
}
|
||||||
|
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
export const getDestinationsForUser = async (
|
all_answers_on_my_markets: {
|
||||||
privateUser: PrivateUser,
|
simple: 'Answers on your markets',
|
||||||
reason: notification_reason_types | keyof notification_subscription_types
|
detailed: 'Answers on your own markets',
|
||||||
) => {
|
},
|
||||||
const notificationSettings = privateUser.notificationPreferences
|
all_comments_on_my_markets: {
|
||||||
let destinations
|
simple: 'Comments on your markets',
|
||||||
let subscriptionType: keyof notification_subscription_types | undefined
|
detailed: 'Comments on your own markets',
|
||||||
if (Object.keys(notificationSettings).includes(reason)) {
|
},
|
||||||
subscriptionType = reason as keyof notification_subscription_types
|
answers_by_followed_users_on_watched_markets: {
|
||||||
destinations = notificationSettings[subscriptionType]
|
simple: 'Only answers by users you follow',
|
||||||
} else {
|
detailed: "Only answers by users you follow on markets you're watching",
|
||||||
const key = reason as notification_reason_types
|
},
|
||||||
subscriptionType = notificationReasonToSubscriptionType[key]
|
answers_by_market_creator_on_watched_markets: {
|
||||||
destinations = subscriptionType
|
simple: 'Only answers by market creator',
|
||||||
? notificationSettings[subscriptionType]
|
detailed: "Only answers by market creator on markets you're watching",
|
||||||
: []
|
},
|
||||||
}
|
betting_streaks: {
|
||||||
// const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
simple: 'For predictions made over consecutive days',
|
||||||
return {
|
detailed: 'Bonuses for predictions made over consecutive days',
|
||||||
sendToEmail: destinations.includes('email'),
|
},
|
||||||
sendToBrowser: destinations.includes('browser'),
|
comments_by_followed_users_on_watched_markets: {
|
||||||
// unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
simple: 'Only comments by users you follow',
|
||||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
detailed:
|
||||||
}
|
'Only comments by users that you follow on markets that you watch',
|
||||||
|
},
|
||||||
|
contract_from_followed_user: {
|
||||||
|
simple: 'New markets from users you follow',
|
||||||
|
detailed: 'New markets from users you follow',
|
||||||
|
},
|
||||||
|
limit_order_fills: {
|
||||||
|
simple: 'Limit order fills',
|
||||||
|
detailed: 'When your limit order is filled by another user',
|
||||||
|
},
|
||||||
|
loan_income: {
|
||||||
|
simple: 'Automatic loans from your predictions in unresolved markets',
|
||||||
|
detailed:
|
||||||
|
'Automatic loans from your predictions that are locked in unresolved markets',
|
||||||
|
},
|
||||||
|
market_updates_on_watched_markets: {
|
||||||
|
simple: 'All creator updates',
|
||||||
|
detailed: 'All market updates made by the creator',
|
||||||
|
},
|
||||||
|
market_updates_on_watched_markets_with_shares_in: {
|
||||||
|
simple: "Only creator updates on markets that you're invested in",
|
||||||
|
detailed:
|
||||||
|
"Only updates made by the creator on markets that you're invested in",
|
||||||
|
},
|
||||||
|
on_new_follow: {
|
||||||
|
simple: 'A user followed you',
|
||||||
|
detailed: 'A user followed you',
|
||||||
|
},
|
||||||
|
onboarding_flow: {
|
||||||
|
simple: 'Emails to help you get started using Manifold',
|
||||||
|
detailed: 'Emails to help you learn how to use Manifold',
|
||||||
|
},
|
||||||
|
probability_updates_on_watched_markets: {
|
||||||
|
simple: 'Large changes in probability on markets that you watch',
|
||||||
|
detailed: 'Large changes in probability on markets that you watch',
|
||||||
|
},
|
||||||
|
profit_loss_updates: {
|
||||||
|
simple: 'Weekly profit and loss updates',
|
||||||
|
detailed: 'Weekly profit and loss updates',
|
||||||
|
},
|
||||||
|
referral_bonuses: {
|
||||||
|
simple: 'For referring new users',
|
||||||
|
detailed: 'Bonuses you receive from referring a new user',
|
||||||
|
},
|
||||||
|
resolutions_on_watched_markets: {
|
||||||
|
simple: 'All market resolutions',
|
||||||
|
detailed: "All resolutions on markets that you're watching",
|
||||||
|
},
|
||||||
|
resolutions_on_watched_markets_with_shares_in: {
|
||||||
|
simple: "Only market resolutions that you're invested in",
|
||||||
|
detailed:
|
||||||
|
"Only resolutions of markets you're watching and that you're invested in",
|
||||||
|
},
|
||||||
|
subsidized_your_market: {
|
||||||
|
simple: 'Your market was subsidized',
|
||||||
|
detailed: 'When someone subsidizes your market',
|
||||||
|
},
|
||||||
|
tagged_user: {
|
||||||
|
simple: 'A user tagged you',
|
||||||
|
detailed: 'When another use tags you',
|
||||||
|
},
|
||||||
|
thank_you_for_purchases: {
|
||||||
|
simple: 'Thank you notes for your purchases',
|
||||||
|
detailed: 'Thank you notes for your purchases',
|
||||||
|
},
|
||||||
|
tipped_comments_on_watched_markets: {
|
||||||
|
simple: 'Only highly tipped comments on markets that you watch',
|
||||||
|
detailed: 'Only highly tipped comments on markets that you watch',
|
||||||
|
},
|
||||||
|
tips_on_your_comments: {
|
||||||
|
simple: 'Tips on your comments',
|
||||||
|
detailed: 'Tips on your comments',
|
||||||
|
},
|
||||||
|
tips_on_your_markets: {
|
||||||
|
simple: 'Tips/Likes on your markets',
|
||||||
|
detailed: 'Tips/Likes on your markets',
|
||||||
|
},
|
||||||
|
trending_markets: {
|
||||||
|
simple: 'Weekly interesting markets',
|
||||||
|
detailed: 'Weekly interesting markets',
|
||||||
|
},
|
||||||
|
unique_bettors_on_your_contract: {
|
||||||
|
simple: 'For unique predictors on your markets',
|
||||||
|
detailed: 'Bonuses for unique predictors on your markets',
|
||||||
|
},
|
||||||
|
your_contract_closed: {
|
||||||
|
simple: 'Your market has closed and you need to resolve it',
|
||||||
|
detailed: 'Your market has closed and you need to resolve it',
|
||||||
|
},
|
||||||
|
all_comments_on_watched_markets: {
|
||||||
|
simple: 'All new comments',
|
||||||
|
detailed: 'All new comments on markets you follow',
|
||||||
|
},
|
||||||
|
all_comments_on_contracts_with_shares_in_on_watched_markets: {
|
||||||
|
simple: `Only on markets you're invested in`,
|
||||||
|
detailed: `Comments on markets that you're watching and you're invested in`,
|
||||||
|
},
|
||||||
|
all_replies_to_my_comments_on_watched_markets: {
|
||||||
|
simple: 'Only replies to your comments',
|
||||||
|
detailed: "Only replies to your comments on markets you're watching",
|
||||||
|
},
|
||||||
|
all_replies_to_my_answers_on_watched_markets: {
|
||||||
|
simple: 'Only replies to your answers',
|
||||||
|
detailed: "Only replies to your answers on markets you're watching",
|
||||||
|
},
|
||||||
|
all_answers_on_watched_markets: {
|
||||||
|
simple: 'All new answers',
|
||||||
|
detailed: "All new answers on markets you're watching",
|
||||||
|
},
|
||||||
|
all_answers_on_contracts_with_shares_in_on_watched_markets: {
|
||||||
|
simple: `Only on markets you're invested in`,
|
||||||
|
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BettingStreakData = {
|
export type BettingStreakData = {
|
||||||
streak: number
|
streak: number
|
||||||
bonusAmount: number
|
bonusAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BetFillData = {
|
||||||
|
betOutcome: string
|
||||||
|
creatorOutcome: string
|
||||||
|
probability: number
|
||||||
|
fillAmount: number
|
||||||
|
limitOrderTotal?: number
|
||||||
|
limitOrderRemaining?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContractResolutionData = {
|
||||||
|
outcome: string
|
||||||
|
userPayout: number
|
||||||
|
userInvestment: number
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||||
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
|
type AnyTxnType =
|
||||||
|
| Donation
|
||||||
|
| Tip
|
||||||
|
| Manalink
|
||||||
|
| Referral
|
||||||
|
| UniqueBettorBonus
|
||||||
|
| BettingStreakBonus
|
||||||
|
| CancelUniqueBettorBonus
|
||||||
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> = {
|
||||||
|
@ -23,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
| 'REFERRAL'
|
| 'REFERRAL'
|
||||||
| 'UNIQUE_BETTOR_BONUS'
|
| 'UNIQUE_BETTOR_BONUS'
|
||||||
| 'BETTING_STREAK_BONUS'
|
| 'BETTING_STREAK_BONUS'
|
||||||
|
| 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||||
|
|
||||||
// Any extra data
|
// Any extra data
|
||||||
data?: { [key: string]: any }
|
data?: { [key: string]: any }
|
||||||
|
@ -60,13 +68,40 @@ type Referral = {
|
||||||
category: 'REFERRAL'
|
category: 'REFERRAL'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Bonus = {
|
type UniqueBettorBonus = {
|
||||||
fromType: 'BANK'
|
fromType: 'BANK'
|
||||||
toType: 'USER'
|
toType: 'USER'
|
||||||
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
|
category: 'UNIQUE_BETTOR_BONUS'
|
||||||
|
data: {
|
||||||
|
contractId: string
|
||||||
|
uniqueNewBettorId?: string
|
||||||
|
// Old unique bettor bonus txns stored all unique bettor ids
|
||||||
|
uniqueBettorIds?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BettingStreakBonus = {
|
||||||
|
fromType: 'BANK'
|
||||||
|
toType: 'USER'
|
||||||
|
category: 'BETTING_STREAK_BONUS'
|
||||||
|
data: {
|
||||||
|
currentBettingStreak?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelUniqueBettorBonus = {
|
||||||
|
fromType: 'USER'
|
||||||
|
toType: 'BANK'
|
||||||
|
category: 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||||
|
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
|
||||||
export type ReferralTxn = Txn & Referral
|
export type ReferralTxn = Txn & Referral
|
||||||
|
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
|
||||||
|
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
|
||||||
|
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
|
||||||
|
|
194
common/user-notification-preferences.ts
Normal file
194
common/user-notification-preferences.ts
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
import { filterDefined } from './util/array'
|
||||||
|
import { notification_reason_types } from './notification'
|
||||||
|
import { getFunctionUrl } from './api'
|
||||||
|
import { DOMAIN } from './envs/constants'
|
||||||
|
import { PrivateUser } from './user'
|
||||||
|
|
||||||
|
export type notification_destination_types = 'email' | 'browser'
|
||||||
|
export type notification_preference = keyof notification_preferences
|
||||||
|
export type notification_preferences = {
|
||||||
|
// Watched Markets
|
||||||
|
all_comments_on_watched_markets: notification_destination_types[]
|
||||||
|
all_answers_on_watched_markets: notification_destination_types[]
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
tipped_comments_on_watched_markets: notification_destination_types[]
|
||||||
|
comments_by_followed_users_on_watched_markets: notification_destination_types[]
|
||||||
|
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
|
||||||
|
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
|
||||||
|
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
||||||
|
|
||||||
|
// Answers
|
||||||
|
answers_by_followed_users_on_watched_markets: notification_destination_types[]
|
||||||
|
answers_by_market_creator_on_watched_markets: notification_destination_types[]
|
||||||
|
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
||||||
|
|
||||||
|
// On users' markets
|
||||||
|
your_contract_closed: notification_destination_types[]
|
||||||
|
all_comments_on_my_markets: notification_destination_types[]
|
||||||
|
all_answers_on_my_markets: notification_destination_types[]
|
||||||
|
subsidized_your_market: notification_destination_types[]
|
||||||
|
|
||||||
|
// Market updates
|
||||||
|
resolutions_on_watched_markets: notification_destination_types[]
|
||||||
|
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
|
||||||
|
market_updates_on_watched_markets: notification_destination_types[]
|
||||||
|
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
|
||||||
|
probability_updates_on_watched_markets: notification_destination_types[]
|
||||||
|
|
||||||
|
// Balance Changes
|
||||||
|
loan_income: notification_destination_types[]
|
||||||
|
betting_streaks: notification_destination_types[]
|
||||||
|
referral_bonuses: notification_destination_types[]
|
||||||
|
unique_bettors_on_your_contract: notification_destination_types[]
|
||||||
|
tips_on_your_comments: notification_destination_types[]
|
||||||
|
tips_on_your_markets: notification_destination_types[]
|
||||||
|
limit_order_fills: notification_destination_types[]
|
||||||
|
|
||||||
|
// General
|
||||||
|
tagged_user: notification_destination_types[]
|
||||||
|
on_new_follow: notification_destination_types[]
|
||||||
|
contract_from_followed_user: notification_destination_types[]
|
||||||
|
trending_markets: notification_destination_types[]
|
||||||
|
profit_loss_updates: notification_destination_types[]
|
||||||
|
onboarding_flow: notification_destination_types[]
|
||||||
|
thank_you_for_purchases: notification_destination_types[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDefaultNotificationPreferences = (
|
||||||
|
userId: string,
|
||||||
|
privateUser?: PrivateUser,
|
||||||
|
noEmails?: boolean
|
||||||
|
) => {
|
||||||
|
const constructPref = (browserIf: boolean, emailIf: boolean) => {
|
||||||
|
const browser = browserIf ? 'browser' : undefined
|
||||||
|
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||||
|
return filterDefined([browser, email]) as notification_destination_types[]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// Watched Markets
|
||||||
|
all_comments_on_watched_markets: constructPref(true, false),
|
||||||
|
all_answers_on_watched_markets: constructPref(true, false),
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
tips_on_your_comments: constructPref(true, true),
|
||||||
|
comments_by_followed_users_on_watched_markets: constructPref(true, true),
|
||||||
|
all_replies_to_my_comments_on_watched_markets: constructPref(true, true),
|
||||||
|
all_replies_to_my_answers_on_watched_markets: constructPref(true, true),
|
||||||
|
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
|
||||||
|
// Answers
|
||||||
|
answers_by_followed_users_on_watched_markets: constructPref(true, true),
|
||||||
|
answers_by_market_creator_on_watched_markets: constructPref(true, true),
|
||||||
|
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
|
||||||
|
// On users' markets
|
||||||
|
your_contract_closed: constructPref(true, true), // High priority
|
||||||
|
all_comments_on_my_markets: constructPref(true, true),
|
||||||
|
all_answers_on_my_markets: constructPref(true, true),
|
||||||
|
subsidized_your_market: constructPref(true, true),
|
||||||
|
|
||||||
|
// Market updates
|
||||||
|
resolutions_on_watched_markets: constructPref(true, false),
|
||||||
|
market_updates_on_watched_markets: constructPref(true, false),
|
||||||
|
market_updates_on_watched_markets_with_shares_in: constructPref(
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
resolutions_on_watched_markets_with_shares_in: constructPref(true, true),
|
||||||
|
|
||||||
|
//Balance Changes
|
||||||
|
loan_income: constructPref(true, false),
|
||||||
|
betting_streaks: constructPref(true, false),
|
||||||
|
referral_bonuses: constructPref(true, true),
|
||||||
|
unique_bettors_on_your_contract: constructPref(true, false),
|
||||||
|
tipped_comments_on_watched_markets: constructPref(true, true),
|
||||||
|
tips_on_your_markets: constructPref(true, true),
|
||||||
|
limit_order_fills: constructPref(true, false),
|
||||||
|
|
||||||
|
// General
|
||||||
|
tagged_user: constructPref(true, true),
|
||||||
|
on_new_follow: constructPref(true, true),
|
||||||
|
contract_from_followed_user: constructPref(true, true),
|
||||||
|
trending_markets: constructPref(false, true),
|
||||||
|
profit_loss_updates: constructPref(false, true),
|
||||||
|
probability_updates_on_watched_markets: constructPref(true, false),
|
||||||
|
thank_you_for_purchases: constructPref(false, false),
|
||||||
|
onboarding_flow: constructPref(false, false),
|
||||||
|
} as notification_preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||||
|
// You might want to add a key:value here if there will be multiple notification reasons that map to the same
|
||||||
|
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
|
||||||
|
// 'all_comments_on_watched_markets' subscription type
|
||||||
|
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
|
||||||
|
const notificationReasonToSubscriptionType: Partial<
|
||||||
|
Record<notification_reason_types, notification_preference>
|
||||||
|
> = {
|
||||||
|
you_referred_user: 'referral_bonuses',
|
||||||
|
user_joined_to_bet_on_your_market: 'referral_bonuses',
|
||||||
|
tip_received: 'tips_on_your_comments',
|
||||||
|
bet_fill: 'limit_order_fills',
|
||||||
|
user_joined_from_your_group_invite: 'referral_bonuses',
|
||||||
|
challenge_accepted: 'limit_order_fills',
|
||||||
|
betting_streak_incremented: 'betting_streaks',
|
||||||
|
liked_and_tipped_your_contract: 'tips_on_your_markets',
|
||||||
|
comment_on_your_contract: 'all_comments_on_my_markets',
|
||||||
|
answer_on_your_contract: 'all_answers_on_my_markets',
|
||||||
|
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
|
||||||
|
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
|
||||||
|
update_on_contract_you_follow: 'market_updates_on_watched_markets',
|
||||||
|
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
|
||||||
|
comment_on_contract_with_users_shares_in:
|
||||||
|
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
answer_on_contract_with_users_shares_in:
|
||||||
|
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
update_on_contract_with_users_shares_in:
|
||||||
|
'market_updates_on_watched_markets_with_shares_in',
|
||||||
|
resolution_on_contract_with_users_shares_in:
|
||||||
|
'resolutions_on_watched_markets_with_shares_in',
|
||||||
|
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
|
||||||
|
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
|
||||||
|
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
|
||||||
|
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
|
||||||
|
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
|
||||||
|
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
|
||||||
|
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
|
||||||
|
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
|
||||||
|
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
|
||||||
|
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNotificationDestinationsForUser = (
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
// TODO: accept reasons array from most to least important and work backwards
|
||||||
|
reason: notification_reason_types | notification_preference
|
||||||
|
) => {
|
||||||
|
const notificationSettings = privateUser.notificationPreferences
|
||||||
|
let destinations
|
||||||
|
let subscriptionType: notification_preference | undefined
|
||||||
|
if (Object.keys(notificationSettings).includes(reason)) {
|
||||||
|
subscriptionType = reason as notification_preference
|
||||||
|
destinations = notificationSettings[subscriptionType]
|
||||||
|
} else {
|
||||||
|
const key = reason as notification_reason_types
|
||||||
|
subscriptionType = notificationReasonToSubscriptionType[key]
|
||||||
|
destinations = subscriptionType
|
||||||
|
? notificationSettings[subscriptionType]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
|
return {
|
||||||
|
sendToEmail: destinations.includes('email'),
|
||||||
|
sendToBrowser: destinations.includes('browser'),
|
||||||
|
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||||
|
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||||
|
}
|
||||||
|
}
|
181
common/user.ts
181
common/user.ts
|
@ -1,4 +1,5 @@
|
||||||
import { filterDefined } from './util/array'
|
import { notification_preferences } from './user-notification-preferences'
|
||||||
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -65,65 +66,15 @@ export type PrivateUser = {
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
notificationPreferences: notification_subscription_types
|
notificationPreferences: notification_preferences
|
||||||
twitchInfo?: {
|
twitchInfo?: {
|
||||||
twitchName: string
|
twitchName: string
|
||||||
controlToken: string
|
controlToken: string
|
||||||
botEnabled?: boolean
|
botEnabled?: boolean
|
||||||
|
needsRelinking?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type notification_destination_types = 'email' | 'browser'
|
|
||||||
export type notification_subscription_types = {
|
|
||||||
// Watched Markets
|
|
||||||
all_comments_on_watched_markets: notification_destination_types[]
|
|
||||||
all_answers_on_watched_markets: notification_destination_types[]
|
|
||||||
|
|
||||||
// Comments
|
|
||||||
tipped_comments_on_watched_markets: notification_destination_types[]
|
|
||||||
comments_by_followed_users_on_watched_markets: notification_destination_types[]
|
|
||||||
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
|
|
||||||
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
|
|
||||||
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
|
||||||
|
|
||||||
// Answers
|
|
||||||
answers_by_followed_users_on_watched_markets: notification_destination_types[]
|
|
||||||
answers_by_market_creator_on_watched_markets: notification_destination_types[]
|
|
||||||
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
|
||||||
|
|
||||||
// On users' markets
|
|
||||||
your_contract_closed: notification_destination_types[]
|
|
||||||
all_comments_on_my_markets: notification_destination_types[]
|
|
||||||
all_answers_on_my_markets: notification_destination_types[]
|
|
||||||
subsidized_your_market: notification_destination_types[]
|
|
||||||
|
|
||||||
// Market updates
|
|
||||||
resolutions_on_watched_markets: notification_destination_types[]
|
|
||||||
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
|
|
||||||
market_updates_on_watched_markets: notification_destination_types[]
|
|
||||||
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
|
|
||||||
probability_updates_on_watched_markets: notification_destination_types[]
|
|
||||||
|
|
||||||
// Balance Changes
|
|
||||||
loan_income: notification_destination_types[]
|
|
||||||
betting_streaks: notification_destination_types[]
|
|
||||||
referral_bonuses: notification_destination_types[]
|
|
||||||
unique_bettors_on_your_contract: notification_destination_types[]
|
|
||||||
tips_on_your_comments: notification_destination_types[]
|
|
||||||
tips_on_your_markets: notification_destination_types[]
|
|
||||||
limit_order_fills: notification_destination_types[]
|
|
||||||
|
|
||||||
// General
|
|
||||||
tagged_user: notification_destination_types[]
|
|
||||||
on_new_follow: notification_destination_types[]
|
|
||||||
contract_from_followed_user: notification_destination_types[]
|
|
||||||
trending_markets: notification_destination_types[]
|
|
||||||
profit_loss_updates: notification_destination_types[]
|
|
||||||
onboarding_flow: notification_destination_types[]
|
|
||||||
thank_you_for_purchases: notification_destination_types[]
|
|
||||||
}
|
|
||||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
|
||||||
|
|
||||||
export type PortfolioMetrics = {
|
export type PortfolioMetrics = {
|
||||||
investmentValue: number
|
investmentValue: number
|
||||||
balance: number
|
balance: number
|
||||||
|
@ -135,121 +86,9 @@ export type PortfolioMetrics = {
|
||||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
||||||
export const getDefaultNotificationSettings = (
|
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
|
||||||
userId: string,
|
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
|
||||||
privateUser?: PrivateUser,
|
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
|
||||||
noEmails?: boolean
|
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
|
||||||
) => {
|
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
|
||||||
const {
|
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
|
||||||
unsubscribedFromCommentEmails,
|
|
||||||
unsubscribedFromAnswerEmails,
|
|
||||||
unsubscribedFromResolutionEmails,
|
|
||||||
unsubscribedFromWeeklyTrendingEmails,
|
|
||||||
unsubscribedFromGenericEmails,
|
|
||||||
} = privateUser || {}
|
|
||||||
|
|
||||||
const constructPref = (browserIf: boolean, emailIf: boolean) => {
|
|
||||||
const browser = browserIf ? 'browser' : undefined
|
|
||||||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
|
||||||
return filterDefined([browser, email]) as notification_destination_types[]
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
// Watched Markets
|
|
||||||
all_comments_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
all_answers_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromAnswerEmails
|
|
||||||
),
|
|
||||||
|
|
||||||
// Comments
|
|
||||||
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails),
|
|
||||||
comments_by_followed_users_on_watched_markets: constructPref(true, false),
|
|
||||||
all_replies_to_my_comments_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
all_replies_to_my_answers_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
|
|
||||||
// Answers
|
|
||||||
answers_by_followed_users_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromAnswerEmails
|
|
||||||
),
|
|
||||||
answers_by_market_creator_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromAnswerEmails
|
|
||||||
),
|
|
||||||
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromAnswerEmails
|
|
||||||
),
|
|
||||||
|
|
||||||
// On users' markets
|
|
||||||
your_contract_closed: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromResolutionEmails
|
|
||||||
), // High priority
|
|
||||||
all_comments_on_my_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
all_answers_on_my_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromAnswerEmails
|
|
||||||
),
|
|
||||||
subsidized_your_market: constructPref(true, true),
|
|
||||||
|
|
||||||
// Market updates
|
|
||||||
resolutions_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromResolutionEmails
|
|
||||||
),
|
|
||||||
market_updates_on_watched_markets: constructPref(true, false),
|
|
||||||
market_updates_on_watched_markets_with_shares_in: constructPref(
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
resolutions_on_watched_markets_with_shares_in: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromResolutionEmails
|
|
||||||
),
|
|
||||||
|
|
||||||
//Balance Changes
|
|
||||||
loan_income: constructPref(true, false),
|
|
||||||
betting_streaks: constructPref(true, false),
|
|
||||||
referral_bonuses: constructPref(true, true),
|
|
||||||
unique_bettors_on_your_contract: constructPref(true, false),
|
|
||||||
tipped_comments_on_watched_markets: constructPref(
|
|
||||||
true,
|
|
||||||
!unsubscribedFromCommentEmails
|
|
||||||
),
|
|
||||||
tips_on_your_markets: constructPref(true, true),
|
|
||||||
limit_order_fills: constructPref(true, false),
|
|
||||||
|
|
||||||
// General
|
|
||||||
tagged_user: constructPref(true, true),
|
|
||||||
on_new_follow: constructPref(true, true),
|
|
||||||
contract_from_followed_user: constructPref(true, true),
|
|
||||||
trending_markets: constructPref(
|
|
||||||
false,
|
|
||||||
!unsubscribedFromWeeklyTrendingEmails
|
|
||||||
),
|
|
||||||
profit_loss_updates: constructPref(false, true),
|
|
||||||
probability_updates_on_watched_markets: constructPref(true, false),
|
|
||||||
thank_you_for_purchases: constructPref(
|
|
||||||
false,
|
|
||||||
!unsubscribedFromGenericEmails
|
|
||||||
),
|
|
||||||
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
|
|
||||||
} as notification_subscription_types
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,7 +14,8 @@ service cloud.firestore {
|
||||||
'manticmarkets@gmail.com',
|
'manticmarkets@gmail.com',
|
||||||
'iansphilips@gmail.com',
|
'iansphilips@gmail.com',
|
||||||
'd4vidchee@gmail.com',
|
'd4vidchee@gmail.com',
|
||||||
'federicoruizcassarino@gmail.com'
|
'federicoruizcassarino@gmail.com',
|
||||||
|
'ingawei@gmail.com'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {
|
import {
|
||||||
|
BetFillData,
|
||||||
BettingStreakData,
|
BettingStreakData,
|
||||||
getDestinationsForUser,
|
ContractResolutionData,
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
|
@ -9,7 +10,7 @@ import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getValues } from './utils'
|
import { getPrivateUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { groupBy, uniq } from 'lodash'
|
import { groupBy, sum, uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
|
@ -27,6 +28,8 @@ import {
|
||||||
sendNewUniqueBettorsEmail,
|
sendNewUniqueBettorsEmail,
|
||||||
} from './emails'
|
} from './emails'
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||||
|
import { ContractFollow } from '../../common/follow'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type recipients_to_reason_texts = {
|
type recipients_to_reason_texts = {
|
||||||
|
@ -66,7 +69,7 @@ export const createNotification = async (
|
||||||
const { reason } = userToReasonTexts[userId]
|
const { reason } = userToReasonTexts[userId]
|
||||||
const privateUser = await getPrivateUser(userId)
|
const privateUser = await getPrivateUser(userId)
|
||||||
if (!privateUser) continue
|
if (!privateUser) continue
|
||||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
reason
|
reason
|
||||||
)
|
)
|
||||||
|
@ -158,7 +161,7 @@ export type replied_users_info = {
|
||||||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: 'comment' | 'answer' | 'contract',
|
sourceType: 'comment' | 'answer' | 'contract',
|
||||||
sourceUpdateType: 'created' | 'updated' | 'resolved',
|
sourceUpdateType: 'created' | 'updated',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
|
@ -166,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
miscData?: {
|
miscData?: {
|
||||||
repliedUsersInfo: replied_users_info
|
repliedUsersInfo: replied_users_info
|
||||||
taggedUserIds: string[]
|
taggedUserIds: string[]
|
||||||
},
|
|
||||||
resolutionData?: {
|
|
||||||
bets: Bet[]
|
|
||||||
userInvestments: { [userId: string]: number }
|
|
||||||
userPayouts: { [userId: string]: number }
|
|
||||||
creator: User
|
|
||||||
creatorPayout: number
|
|
||||||
contract: Contract
|
|
||||||
outcome: string
|
|
||||||
resolutionProbability?: number
|
|
||||||
resolutions?: { [outcome: string]: number }
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
|
const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
|
||||||
|
@ -229,14 +221,10 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
reason: notification_reason_types
|
reason: notification_reason_types
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!stillFollowingContract(userId) || sourceUser.id == userId) return
|
||||||
!stillFollowingContract(sourceContract.creatorId) ||
|
|
||||||
sourceUser.id == userId
|
|
||||||
)
|
|
||||||
return
|
|
||||||
const privateUser = await getPrivateUser(userId)
|
const privateUser = await getPrivateUser(userId)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
reason
|
reason
|
||||||
)
|
)
|
||||||
|
@ -275,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
sourceUser.avatarUrl
|
sourceUser.avatarUrl
|
||||||
)
|
)
|
||||||
emailRecipientIdsList.push(userId)
|
emailRecipientIdsList.push(userId)
|
||||||
} else if (
|
|
||||||
sourceType === 'contract' &&
|
|
||||||
sourceUpdateType === 'resolved' &&
|
|
||||||
resolutionData
|
|
||||||
) {
|
|
||||||
await sendMarketResolutionEmail(
|
|
||||||
reason,
|
|
||||||
privateUser,
|
|
||||||
resolutionData.userInvestments[userId] ?? 0,
|
|
||||||
resolutionData.userPayouts[userId] ?? 0,
|
|
||||||
sourceUser,
|
|
||||||
resolutionData.creatorPayout,
|
|
||||||
sourceContract,
|
|
||||||
resolutionData.outcome,
|
|
||||||
resolutionData.resolutionProbability,
|
|
||||||
resolutionData.resolutions
|
|
||||||
)
|
|
||||||
emailRecipientIdsList.push(userId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -446,6 +416,9 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: store all possible reasons why the user might be getting the notification
|
||||||
|
// and choose the most lenient that they have enabled so they will unsubscribe
|
||||||
|
// from the least important notifications
|
||||||
await notifyRepliedUser()
|
await notifyRepliedUser()
|
||||||
await notifyTaggedUsers()
|
await notifyTaggedUsers()
|
||||||
await notifyContractCreator()
|
await notifyContractCreator()
|
||||||
|
@ -468,7 +441,7 @@ export const createTipNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'tip_received'
|
'tip_received'
|
||||||
)
|
)
|
||||||
|
@ -507,20 +480,22 @@ export const createBetFillNotification = async (
|
||||||
fromUser: User,
|
fromUser: User,
|
||||||
toUser: User,
|
toUser: User,
|
||||||
bet: Bet,
|
bet: Bet,
|
||||||
userBet: LimitBet,
|
limitBet: LimitBet,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'bet_fill'
|
'bet_fill'
|
||||||
)
|
)
|
||||||
if (!sendToBrowser) return
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
|
const fill = limitBet.fills.find((fill) => fill.matchedBetId === bet.id)
|
||||||
const fillAmount = fill?.amount ?? 0
|
const fillAmount = fill?.amount ?? 0
|
||||||
|
const remainingAmount =
|
||||||
|
limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount))
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
|
@ -531,7 +506,7 @@ export const createBetFillNotification = async (
|
||||||
reason: 'bet_fill',
|
reason: 'bet_fill',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: userBet.id,
|
sourceId: limitBet.id,
|
||||||
sourceType: 'bet',
|
sourceType: 'bet',
|
||||||
sourceUpdateType: 'updated',
|
sourceUpdateType: 'updated',
|
||||||
sourceUserName: fromUser.name,
|
sourceUserName: fromUser.name,
|
||||||
|
@ -542,6 +517,14 @@ export const createBetFillNotification = async (
|
||||||
sourceContractTitle: contract.question,
|
sourceContractTitle: contract.question,
|
||||||
sourceContractSlug: contract.slug,
|
sourceContractSlug: contract.slug,
|
||||||
sourceContractId: contract.id,
|
sourceContractId: contract.id,
|
||||||
|
data: {
|
||||||
|
betOutcome: bet.outcome,
|
||||||
|
creatorOutcome: limitBet.outcome,
|
||||||
|
fillAmount,
|
||||||
|
probability: limitBet.limitProb,
|
||||||
|
limitOrderTotal: limitBet.orderAmount,
|
||||||
|
limitOrderRemaining: remainingAmount,
|
||||||
|
} as BetFillData,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
@ -558,7 +541,7 @@ export const createReferralNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'you_referred_user'
|
'you_referred_user'
|
||||||
)
|
)
|
||||||
|
@ -612,7 +595,7 @@ export const createLoanIncomeNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'loan_income'
|
'loan_income'
|
||||||
)
|
)
|
||||||
|
@ -650,7 +633,7 @@ export const createChallengeAcceptedNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(challengeCreator.id)
|
const privateUser = await getPrivateUser(challengeCreator.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'challenge_accepted'
|
'challenge_accepted'
|
||||||
)
|
)
|
||||||
|
@ -692,7 +675,7 @@ export const createBettingStreakBonusNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(user.id)
|
const privateUser = await getPrivateUser(user.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'betting_streak_incremented'
|
'betting_streak_incremented'
|
||||||
)
|
)
|
||||||
|
@ -739,7 +722,7 @@ export const createLikeNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser } = await getDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'liked_and_tipped_your_contract'
|
'liked_and_tipped_your_contract'
|
||||||
)
|
)
|
||||||
|
@ -786,7 +769,7 @@ export const createUniqueBettorBonusNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(contractCreatorId)
|
const privateUser = await getPrivateUser(contractCreatorId)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'unique_bettors_on_your_contract'
|
'unique_bettors_on_your_contract'
|
||||||
)
|
)
|
||||||
|
@ -876,7 +859,7 @@ export const createNewContractNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const privateUser = await getPrivateUser(userId)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
reason
|
reason
|
||||||
)
|
)
|
||||||
|
@ -936,3 +919,130 @@ export const createNewContractNotification = async (
|
||||||
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
|
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createContractResolvedNotifications = async (
|
||||||
|
contract: Contract,
|
||||||
|
creator: User,
|
||||||
|
outcome: string,
|
||||||
|
probabilityInt: number | undefined,
|
||||||
|
resolutionValue: number | undefined,
|
||||||
|
resolutionData: {
|
||||||
|
bets: Bet[]
|
||||||
|
userInvestments: { [userId: string]: number }
|
||||||
|
userPayouts: { [userId: string]: number }
|
||||||
|
creator: User
|
||||||
|
creatorPayout: number
|
||||||
|
contract: Contract
|
||||||
|
outcome: string
|
||||||
|
resolutionProbability?: number
|
||||||
|
resolutions?: { [outcome: string]: number }
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
let resolutionText = outcome ?? contract.question
|
||||||
|
if (
|
||||||
|
contract.outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
) {
|
||||||
|
const answerText = contract.answers.find(
|
||||||
|
(answer) => answer.id === outcome
|
||||||
|
)?.text
|
||||||
|
if (answerText) resolutionText = answerText
|
||||||
|
} else if (contract.outcomeType === 'BINARY') {
|
||||||
|
if (resolutionText === 'MKT' && probabilityInt)
|
||||||
|
resolutionText = `${probabilityInt}%`
|
||||||
|
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
||||||
|
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
|
if (resolutionText === 'MKT' && resolutionValue)
|
||||||
|
resolutionText = `${resolutionValue}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = contract.id + '-resolved'
|
||||||
|
const createBrowserNotification = async (
|
||||||
|
userId: string,
|
||||||
|
reason: notification_reason_types
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${userId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: contract.id,
|
||||||
|
sourceType: 'contract',
|
||||||
|
sourceUpdateType: 'resolved',
|
||||||
|
sourceContractId: contract.id,
|
||||||
|
sourceUserName: creator.name,
|
||||||
|
sourceUserUsername: creator.username,
|
||||||
|
sourceUserAvatarUrl: creator.avatarUrl,
|
||||||
|
sourceText: resolutionText,
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceSlug: contract.slug,
|
||||||
|
sourceTitle: contract.question,
|
||||||
|
data: {
|
||||||
|
outcome,
|
||||||
|
userInvestment: resolutionData.userInvestments[userId] ?? 0,
|
||||||
|
userPayout: resolutionData.userPayouts[userId] ?? 0,
|
||||||
|
} as ContractResolutionData,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendNotificationsIfSettingsPermit = async (
|
||||||
|
userId: string,
|
||||||
|
reason: notification_reason_types
|
||||||
|
) => {
|
||||||
|
if (!stillFollowingContract(userId) || creator.id == userId) return
|
||||||
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
|
||||||
|
// Browser notifications
|
||||||
|
if (sendToBrowser) {
|
||||||
|
await createBrowserNotification(userId, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emails notifications
|
||||||
|
if (sendToEmail)
|
||||||
|
await sendMarketResolutionEmail(
|
||||||
|
reason,
|
||||||
|
privateUser,
|
||||||
|
resolutionData.userInvestments[userId] ?? 0,
|
||||||
|
resolutionData.userPayouts[userId] ?? 0,
|
||||||
|
creator,
|
||||||
|
resolutionData.creatorPayout,
|
||||||
|
contract,
|
||||||
|
resolutionData.outcome,
|
||||||
|
resolutionData.resolutionProbability,
|
||||||
|
resolutionData.resolutions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractFollowersIds = (
|
||||||
|
await getValues<ContractFollow>(
|
||||||
|
firestore.collection(`contracts/${contract.id}/follows`)
|
||||||
|
)
|
||||||
|
).map((follow) => follow.id)
|
||||||
|
|
||||||
|
const stillFollowingContract = (userId: string) => {
|
||||||
|
return contractFollowersIds.includes(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
contractFollowersIds.map((id) =>
|
||||||
|
sendNotificationsIfSettingsPermit(
|
||||||
|
id,
|
||||||
|
resolutionData.userInvestments[id]
|
||||||
|
? 'resolution_on_contract_with_users_shares_in'
|
||||||
|
: 'resolution_on_contract_you_follow'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import {
|
import { PrivateUser, User } from '../../common/user'
|
||||||
getDefaultNotificationSettings,
|
|
||||||
PrivateUser,
|
|
||||||
User,
|
|
||||||
} from '../../common/user'
|
|
||||||
import { getUser, getUserByUsername, getValues } from './utils'
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import {
|
import {
|
||||||
|
@ -22,6 +18,7 @@ import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||||
|
import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
deviceToken: z.string().optional(),
|
deviceToken: z.string().optional(),
|
||||||
|
@ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
email,
|
email,
|
||||||
initialIpAddress: req.ip,
|
initialIpAddress: req.ip,
|
||||||
initialDeviceToken: deviceToken,
|
initialDeviceToken: deviceToken,
|
||||||
notificationPreferences: getDefaultNotificationSettings(auth.uid),
|
notificationPreferences: getDefaultNotificationPreferences(auth.uid),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
|
@ -1,321 +0,0 @@
|
||||||
<!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 align="center"
|
|
||||||
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
style="border-collapse:collapse;border-spacing:0px;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
|
||||||
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
|
||||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
|
||||||
width="550"></a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<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;">
|
|
||||||
Hi {{name}},</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;">Thanks for
|
|
||||||
using Manifold Markets. Running low
|
|
||||||
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<p></p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
|
||||||
<a href="{{manalink}}" target="_blank"
|
|
||||||
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
|
|
||||||
Claim M$500
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="left"
|
|
||||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;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: 23px; margin: 10px 0; margin-top: 10px;"
|
|
||||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
|
||||||
you know, besides making correct predictions, there are
|
|
||||||
plenty of other ways to earn mana?</span></p>
|
|
||||||
<ul>
|
|
||||||
<li style="line-height:23px;"><span
|
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
|
||||||
tips on comments</span></li>
|
|
||||||
<li style="line-height:23px;"><span
|
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
|
||||||
trader bonus for each user who bets on your
|
|
||||||
markets</span></li>
|
|
||||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
|
||||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
|
||||||
target="_blank" href="https://manifold.markets/referrals"><span
|
|
||||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
|
||||||
friends</u></span></a></span></li>
|
|
||||||
<li style="line-height:23px;"><a class="link-build-content"
|
|
||||||
style="color:inherit;; text-decoration: none;" target="_blank"
|
|
||||||
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
|
||||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
|
||||||
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
|
||||||
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
|
||||||
target="_blank"
|
|
||||||
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
|
||||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
|
||||||
feedback</u></span></a></li>
|
|
||||||
</ul>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
|
||||||
from Manifold</span></p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="left"
|
|
||||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;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: 23px; margin: 10px 0; margin-top: 10px;"
|
|
||||||
data-testid="3Q8BP69fq"></a></li>
|
|
||||||
</ul>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
|
||||||
style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p>
|
|
||||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
|
||||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
|
||||||
width="100%">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
style="border-collapse:collapse;border-spacing:0px;">
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
|
||||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
|
||||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="vertical-align:top;padding:0;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align="center"
|
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
|
||||||
<div
|
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
|
||||||
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"
|
|
||||||
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -494,7 +494,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -443,7 +443,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -529,7 +529,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -369,7 +369,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -487,7 +487,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -369,7 +369,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -470,7 +470,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -502,7 +502,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -318,7 +318,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -376,7 +376,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -480,7 +480,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -283,7 +283,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -218,7 +218,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -290,7 +290,7 @@
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to manage your notifications</a>.
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import {
|
import { PrivateUser, User } from '../../common/user'
|
||||||
notification_subscription_types,
|
|
||||||
PrivateUser,
|
|
||||||
User,
|
|
||||||
} from '../../common/user'
|
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
import {
|
import { notification_reason_types } from '../../common/notification'
|
||||||
notification_reason_types,
|
|
||||||
getDestinationsForUser,
|
|
||||||
} from '../../common/notification'
|
|
||||||
import { Dictionary } from 'lodash'
|
import { Dictionary } from 'lodash'
|
||||||
|
import {
|
||||||
|
getNotificationDestinationsForUser,
|
||||||
|
notification_preference,
|
||||||
|
} from '../../common/user-notification-preferences'
|
||||||
|
|
||||||
export const sendMarketResolutionEmail = async (
|
export const sendMarketResolutionEmail = async (
|
||||||
reason: notification_reason_types,
|
reason: notification_reason_types,
|
||||||
|
@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async (
|
||||||
resolutionProbability?: number,
|
resolutionProbability?: number,
|
||||||
resolutions?: { [outcome: string]: number }
|
resolutions?: { [outcome: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
if (!privateUser || !privateUser.email || !sendToEmail) return
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
|
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
|
@ -154,7 +153,7 @@ export const sendWelcomeEmail = async (
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
'onboarding_flow' as keyof notification_subscription_types
|
'onboarding_flow' as notification_preference
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
|
@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
'onboarding_flow' as keyof notification_subscription_types
|
'onboarding_flow' as notification_preference
|
||||||
}`
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
'onboarding_flow' as keyof notification_subscription_types
|
'onboarding_flow' as notification_preference
|
||||||
}`
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -289,7 +288,7 @@ export const sendThankYouEmail = async (
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
'thank_you_for_purchases' as keyof notification_subscription_types
|
'thank_you_for_purchases' as notification_preference
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
|
@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async (
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
|
||||||
if (!privateUser.email || !sendToEmail) return
|
if (!privateUser.email || !sendToEmail) return
|
||||||
|
|
||||||
|
@ -350,8 +351,10 @@ export const sendNewCommentEmail = async (
|
||||||
answerText?: string,
|
answerText?: string,
|
||||||
answerId?: string
|
answerId?: string
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
if (!privateUser || !privateUser.email || !sendToEmail) return
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
|
|
||||||
const { question } = contract
|
const { question } = contract
|
||||||
|
@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async (
|
||||||
// Don't send the creator's own answers.
|
// Don't send the creator's own answers.
|
||||||
if (privateUser.id === creatorId) return
|
if (privateUser.id === creatorId) return
|
||||||
|
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
if (!privateUser.email || !sendToEmail) return
|
if (!privateUser.email || !sendToEmail) return
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question, creatorUsername, slug } = contract
|
||||||
|
@ -465,7 +470,7 @@ export const sendInterestingMarketsEmail = async (
|
||||||
return
|
return
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
'trending_markets' as keyof notification_subscription_types
|
'trending_markets' as notification_preference
|
||||||
}`
|
}`
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
|
@ -516,8 +521,10 @@ export const sendNewFollowedMarketEmail = async (
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
if (!privateUser.email || !sendToEmail) return
|
if (!privateUser.email || !sendToEmail) return
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
@ -553,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async (
|
||||||
userBets: Dictionary<[Bet, ...Bet[]]>,
|
userBets: Dictionary<[Bet, ...Bet[]]>,
|
||||||
bonusAmount: number
|
bonusAmount: number
|
||||||
) => {
|
) => {
|
||||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
await getDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
if (!privateUser.email || !sendToEmail) return
|
if (!privateUser.email || !sendToEmail) return
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { User } from '../../common/user'
|
||||||
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
||||||
import { addHouseLiquidity } from './add-liquidity'
|
import { addHouseLiquidity } from './add-liquidity'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||||
|
@ -109,6 +110,7 @@ const updateBettingStreak = async (
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
currentBettingStreak: newBettingStreak,
|
currentBettingStreak: newBettingStreak,
|
||||||
}
|
}
|
||||||
|
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUserId,
|
fromId: fromUserId,
|
||||||
|
@ -119,11 +121,14 @@ const updateBettingStreak = async (
|
||||||
token: 'M$',
|
token: 'M$',
|
||||||
category: 'BETTING_STREAK_BONUS',
|
category: 'BETTING_STREAK_BONUS',
|
||||||
description: JSON.stringify(bonusTxnDetails),
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
}
|
data: bonusTxnDetails,
|
||||||
|
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
|
||||||
return await runTxn(trans, bonusTxn)
|
return await runTxn(trans, bonusTxn)
|
||||||
})
|
})
|
||||||
if (!result.txn) {
|
if (!result.txn) {
|
||||||
log("betting streak bonus txn couldn't be made")
|
log("betting streak bonus txn couldn't be made")
|
||||||
|
log('status:', result.status)
|
||||||
|
log('message:', result.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,7 +191,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
// Create combined txn for all new unique bettors
|
// Create combined txn for all new unique bettors
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueNewBettorId: bettor.id,
|
||||||
}
|
}
|
||||||
const fromUserId = isProd()
|
const fromUserId = isProd()
|
||||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
@ -194,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||||
const fromUser = fromSnap.data() as User
|
const fromUser = fromSnap.data() as User
|
||||||
|
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUser.id,
|
fromId: fromUser.id,
|
||||||
|
@ -204,12 +210,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
token: 'M$',
|
token: 'M$',
|
||||||
category: 'UNIQUE_BETTOR_BONUS',
|
category: 'UNIQUE_BETTOR_BONUS',
|
||||||
description: JSON.stringify(bonusTxnDetails),
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
}
|
data: bonusTxnDetails,
|
||||||
|
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||||
return await runTxn(trans, bonusTxn)
|
return await runTxn(trans, bonusTxn)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.status != 'success' || !result.txn) {
|
if (result.status != 'success' || !result.txn) {
|
||||||
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
|
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
|
||||||
|
log('message:', result.message)
|
||||||
} else {
|
} else {
|
||||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
||||||
await createUniqueBettorBonusNotification(
|
await createUniqueBettorBonusNotification(
|
||||||
|
|
|
@ -13,6 +13,10 @@ export const onUpdateContract = functions.firestore
|
||||||
if (!contractUpdater) throw new Error('Could not find contract updater')
|
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||||
|
|
||||||
const previousValue = change.before.data() as Contract
|
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 ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.question !== contract.question
|
previousValue.question !== contract.question
|
||||||
|
|
|
@ -9,19 +9,25 @@ import {
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, getValues, isProd, log, payUser } from './utils'
|
||||||
import {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
groupPayoutsByUser,
|
groupPayoutsByUser,
|
||||||
Payout,
|
Payout,
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
import { isManifoldId } from '../../common/envs/constants'
|
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
import { createContractResolvedNotifications } from './create-notification'
|
||||||
|
import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn'
|
||||||
|
import { runTxn, TxnData } from './transact'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from '../../common/antes'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -76,13 +82,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
throw new APIError(404, 'No contract exists with the provided ID')
|
throw new APIError(404, 'No contract exists with the provided ID')
|
||||||
const contract = contractSnap.data() as Contract
|
const contract = contractSnap.data() as Contract
|
||||||
const { creatorId, closeTime } = contract
|
const { creatorId, closeTime } = contract
|
||||||
|
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||||
|
|
||||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||||
contract,
|
contract,
|
||||||
req.body
|
req.body
|
||||||
)
|
)
|
||||||
|
|
||||||
if (creatorId !== auth.uid && !isManifoldId(auth.uid))
|
if (
|
||||||
|
creatorId !== auth.uid &&
|
||||||
|
!isManifoldId(auth.uid) &&
|
||||||
|
!isAdmin(firebaseUser.email)
|
||||||
|
)
|
||||||
throw new APIError(403, 'User is not creator of contract')
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||||
|
@ -158,6 +169,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
await processPayouts(liquidityPayouts, true)
|
await processPayouts(liquidityPayouts, true)
|
||||||
|
|
||||||
await processPayouts([...payouts, ...loanPayouts])
|
await processPayouts([...payouts, ...loanPayouts])
|
||||||
|
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
|
@ -165,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
groupBy(bets, (bet) => bet.userId),
|
groupBy(bets, (bet) => bet.userId),
|
||||||
(bets) => getContractBetMetrics(contract, bets).invested
|
(bets) => getContractBetMetrics(contract, bets).invested
|
||||||
)
|
)
|
||||||
let resolutionText = outcome ?? contract.question
|
|
||||||
if (
|
|
||||||
contract.outcomeType === 'FREE_RESPONSE' ||
|
|
||||||
contract.outcomeType === 'MULTIPLE_CHOICE'
|
|
||||||
) {
|
|
||||||
const answerText = contract.answers.find(
|
|
||||||
(answer) => answer.id === outcome
|
|
||||||
)?.text
|
|
||||||
if (answerText) resolutionText = answerText
|
|
||||||
} else if (contract.outcomeType === 'BINARY') {
|
|
||||||
if (resolutionText === 'MKT' && probabilityInt)
|
|
||||||
resolutionText = `${probabilityInt}%`
|
|
||||||
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
|
||||||
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
|
||||||
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this actually may be too slow to complete with a ton of users to notify?
|
await createContractResolvedNotifications(
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'resolved',
|
|
||||||
creator,
|
|
||||||
contract.id + '-resolution',
|
|
||||||
resolutionText,
|
|
||||||
contract,
|
contract,
|
||||||
undefined,
|
creator,
|
||||||
|
outcome,
|
||||||
|
probabilityInt,
|
||||||
|
value,
|
||||||
{
|
{
|
||||||
bets,
|
bets,
|
||||||
userInvestments,
|
userInvestments,
|
||||||
|
@ -294,4 +286,55 @@ function validateAnswer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function undoUniqueBettorRewardsIfCancelResolution(
|
||||||
|
contract: Contract,
|
||||||
|
outcome: string
|
||||||
|
) {
|
||||||
|
if (outcome === 'CANCEL') {
|
||||||
|
const creatorsBonusTxns = await getValues<Txn>(
|
||||||
|
firestore
|
||||||
|
.collection('txns')
|
||||||
|
.where('category', '==', 'UNIQUE_BETTOR_BONUS')
|
||||||
|
.where('toId', '==', contract.creatorId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const bonusTxnsOnThisContract = creatorsBonusTxns.filter(
|
||||||
|
(txn) => txn.data && txn.data.contractId === contract.id
|
||||||
|
)
|
||||||
|
log('total bonusTxnsOnThisContract', bonusTxnsOnThisContract.length)
|
||||||
|
const totalBonusAmount = sumBy(bonusTxnsOnThisContract, (txn) => txn.amount)
|
||||||
|
log('totalBonusAmount to be withdrawn', totalBonusAmount)
|
||||||
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
|
const bonusTxn: TxnData = {
|
||||||
|
fromId: contract.creatorId,
|
||||||
|
fromType: 'USER',
|
||||||
|
toId: isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
toType: 'BANK',
|
||||||
|
amount: totalBonusAmount,
|
||||||
|
token: 'M$',
|
||||||
|
category: 'CANCEL_UNIQUE_BETTOR_BONUS',
|
||||||
|
data: {
|
||||||
|
contractId: contract.id,
|
||||||
|
},
|
||||||
|
} as Omit<CancelUniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||||
|
return await runTxn(trans, bonusTxn)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status != 'success' || !result.txn) {
|
||||||
|
log(
|
||||||
|
`Couldn't cancel bonus for user: ${contract.creatorId} - status:`,
|
||||||
|
result.status
|
||||||
|
)
|
||||||
|
log('message:', result.message)
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`Cancel Bonus txn for user: ${contract.creatorId} completed:`,
|
||||||
|
result.txn?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -4,14 +4,14 @@ import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
import { getValues } from '../utils'
|
import { getValues } from '../utils'
|
||||||
import { Contract } from 'common/lib/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { Comment } from 'common/lib/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet } from 'common/lib/bet'
|
import { Bet } from 'common/bet'
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
} from 'common/lib/antes'
|
} from 'common/antes'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import { getDefaultNotificationSettings } from 'common/user'
|
|
||||||
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
|
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
|
||||||
|
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
@ -17,7 +17,7 @@ async function main() {
|
||||||
.collection('private-users')
|
.collection('private-users')
|
||||||
.doc(privateUser.id)
|
.doc(privateUser.id)
|
||||||
.update({
|
.update({
|
||||||
notificationPreferences: getDefaultNotificationSettings(
|
notificationPreferences: getDefaultNotificationPreferences(
|
||||||
privateUser.id,
|
privateUser.id,
|
||||||
privateUser,
|
privateUser,
|
||||||
disableEmails
|
disableEmails
|
||||||
|
|
|
@ -3,8 +3,9 @@ import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
|
import { PrivateUser, User } from 'common/user'
|
||||||
import { STARTING_BALANCE } from 'common/economy'
|
import { STARTING_BALANCE } from 'common/economy'
|
||||||
|
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ async function main() {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
notificationPreferences: getDefaultNotificationSettings(user.id),
|
notificationPreferences: getDefaultNotificationPreferences(user.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.totalDeposits === undefined) {
|
if (user.totalDeposits === undefined) {
|
||||||
|
|
34
functions/src/scripts/update-bonus-txn-data-fields.ts
Normal file
34
functions/src/scripts/update-bonus-txn-data-fields.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { Txn } from 'common/txn'
|
||||||
|
import { getValues } from 'functions/src/utils'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// get all txns
|
||||||
|
const bonusTxns = await getValues<Txn>(
|
||||||
|
firestore
|
||||||
|
.collection('txns')
|
||||||
|
.where('category', 'in', ['UNIQUE_BETTOR_BONUS', 'BETTING_STREAK_BONUS'])
|
||||||
|
)
|
||||||
|
// JSON parse description field and add to data field
|
||||||
|
const updatedTxns = bonusTxns.map((txn) => {
|
||||||
|
txn.data = txn.description && JSON.parse(txn.description)
|
||||||
|
return txn
|
||||||
|
})
|
||||||
|
console.log('updatedTxns', updatedTxns[0])
|
||||||
|
// update txns
|
||||||
|
await Promise.all(
|
||||||
|
updatedTxns.map((txn) => {
|
||||||
|
return firestore.collection('txns').doc(txn.id).update({
|
||||||
|
data: txn.data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
|
@ -1,79 +1,227 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { EndpointDefinition } from './api'
|
import { EndpointDefinition } from './api'
|
||||||
import { getUser } from './utils'
|
import { getPrivateUser } from './utils'
|
||||||
import { PrivateUser } from '../../common/user'
|
import { PrivateUser } from '../../common/user'
|
||||||
|
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||||
|
import { notification_preference } from '../../common/user-notification-preferences'
|
||||||
|
|
||||||
export const unsubscribe: EndpointDefinition = {
|
export const unsubscribe: EndpointDefinition = {
|
||||||
opts: { method: 'GET', minInstances: 1 },
|
opts: { method: 'GET', minInstances: 1 },
|
||||||
handler: async (req, res) => {
|
handler: async (req, res) => {
|
||||||
const id = req.query.id as string
|
const id = req.query.id as string
|
||||||
let type = req.query.type as string
|
const type = req.query.type as string
|
||||||
if (!id || !type) {
|
if (!id || !type) {
|
||||||
res.status(400).send('Empty id or type parameter.')
|
res.status(400).send('Empty id or subscription type parameter.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`Unsubscribing ${id} from ${type}`)
|
||||||
|
const notificationSubscriptionType = type as notification_preference
|
||||||
|
if (notificationSubscriptionType === undefined) {
|
||||||
|
res.status(400).send('Invalid subscription type parameter.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'market-resolved') type = 'market-resolve'
|
const user = await getPrivateUser(id)
|
||||||
|
|
||||||
if (
|
|
||||||
![
|
|
||||||
'market-resolve',
|
|
||||||
'market-comment',
|
|
||||||
'market-answer',
|
|
||||||
'generic',
|
|
||||||
'weekly-trending',
|
|
||||||
].includes(type)
|
|
||||||
) {
|
|
||||||
res.status(400).send('Invalid type parameter.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(id)
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.send('This user is not currently subscribed or does not exist.')
|
res.send('This user is not currently subscribed or does not exist.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name } = user
|
const previousDestinations =
|
||||||
|
user.notificationPreferences[notificationSubscriptionType]
|
||||||
|
|
||||||
|
console.log(previousDestinations)
|
||||||
|
const { email } = user
|
||||||
|
|
||||||
const update: Partial<PrivateUser> = {
|
const update: Partial<PrivateUser> = {
|
||||||
...(type === 'market-resolve' && {
|
notificationPreferences: {
|
||||||
unsubscribedFromResolutionEmails: true,
|
...user.notificationPreferences,
|
||||||
}),
|
[notificationSubscriptionType]: previousDestinations.filter(
|
||||||
...(type === 'market-comment' && {
|
(destination) => destination !== 'email'
|
||||||
unsubscribedFromCommentEmails: true,
|
),
|
||||||
}),
|
},
|
||||||
...(type === 'market-answer' && {
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
...(type === 'generic' && {
|
|
||||||
unsubscribedFromGenericEmails: true,
|
|
||||||
}),
|
|
||||||
...(type === 'weekly-trending' && {
|
|
||||||
unsubscribedFromWeeklyTrendingEmails: true,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(id).update(update)
|
await firestore.collection('private-users').doc(id).update(update)
|
||||||
|
|
||||||
if (type === 'market-resolve')
|
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
`
|
||||||
|
<!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='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>
|
||||||
|
`
|
||||||
)
|
)
|
||||||
else if (type === 'market-comment')
|
|
||||||
res.send(
|
|
||||||
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
|
||||||
)
|
|
||||||
else if (type === 'market-answer')
|
|
||||||
res.send(
|
|
||||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
|
||||||
)
|
|
||||||
else if (type === 'weekly-trending')
|
|
||||||
res.send(
|
|
||||||
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
|
|
||||||
)
|
|
||||||
else res.send(`${name}, you have been unsubscribed.`)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { chunk } from 'lodash'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import { PrivateUser, User } from '../../common/user'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { Post } from 'common/post'
|
import { Post } from '../../common/post'
|
||||||
|
|
||||||
export const log = (...args: unknown[]) => {
|
export const log = (...args: unknown[]) => {
|
||||||
console.log(`[${new Date().toISOString()}]`, ...args)
|
console.log(`[${new Date().toISOString()}]`, ...args)
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
|
||||||
export function AnswerResolvePanel(props: {
|
export function AnswerResolvePanel(props: {
|
||||||
|
isAdmin: boolean
|
||||||
|
isCreator: boolean
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||||
setResolveOption: (
|
setResolveOption: (
|
||||||
|
@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: {
|
||||||
) => void
|
) => void
|
||||||
chosenAnswers: { [answerId: string]: number }
|
chosenAnswers: { [answerId: string]: number }
|
||||||
}) {
|
}) {
|
||||||
const { contract, resolveOption, setResolveOption, chosenAnswers } = props
|
const {
|
||||||
|
contract,
|
||||||
|
resolveOption,
|
||||||
|
setResolveOption,
|
||||||
|
chosenAnswers,
|
||||||
|
isAdmin,
|
||||||
|
isCreator,
|
||||||
|
} = props
|
||||||
const answers = Object.keys(chosenAnswers)
|
const answers = Object.keys(chosenAnswers)
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-4 rounded">
|
<Col className="gap-4 rounded">
|
||||||
|
<Row className="justify-between">
|
||||||
<div>Resolve your market</div>
|
<div>Resolve your market</div>
|
||||||
|
{isAdmin && !isCreator && (
|
||||||
|
<span className="rounded bg-red-200 p-1 text-xs text-red-600">
|
||||||
|
ADMIN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
<Col className="gap-4 sm:flex-row sm:items-center">
|
<Col className="gap-4 sm:flex-row sm:items-center">
|
||||||
<ChooseCancelSelector
|
<ChooseCancelSelector
|
||||||
className="sm:!flex-row sm:items-center"
|
className="sm:!flex-row sm:items-center"
|
||||||
|
|
|
@ -24,10 +24,13 @@ import { Linkify } from 'web/components/linkify'
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
import { BuyButton } from 'web/components/yes-no-selector'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
|
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
}) {
|
}) {
|
||||||
|
const isAdmin = useAdmin()
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||||
contract
|
contract
|
||||||
|
@ -154,10 +157,13 @@ export function AnswersPanel(props: {
|
||||||
<CreateAnswerPanel contract={contract} />
|
<CreateAnswerPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user?.id === creatorId && !resolution && (
|
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
|
||||||
|
!resolution && (
|
||||||
<>
|
<>
|
||||||
<Spacer h={2} />
|
<Spacer h={2} />
|
||||||
<AnswerResolvePanel
|
<AnswerResolvePanel
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isCreator={user?.id === creatorId}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
resolveOption={resolveOption}
|
resolveOption={resolveOption}
|
||||||
setResolveOption={setResolveOption}
|
setResolveOption={setResolveOption}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
|
import { PRESENT_BET } from 'common/user'
|
||||||
|
|
||||||
/** 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: {
|
||||||
|
@ -36,12 +37,12 @@ export default function BetButton(props: {
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
|
'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize',
|
||||||
btnClassName
|
btnClassName
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
Predict
|
{PRESENT_BET}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
|
|
@ -79,7 +79,7 @@ export function BetInline(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('items-center', className)}>
|
<Col className={clsx('items-center', className)}>
|
||||||
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
||||||
<div className="text-xl">Bet</div>
|
<div className="text-xl">Predict</div>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
className="space-x-0"
|
className="space-x-0"
|
||||||
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||||
|
|
|
@ -11,6 +11,7 @@ export type ColorType =
|
||||||
| 'gray'
|
| 'gray'
|
||||||
| 'gradient'
|
| 'gradient'
|
||||||
| 'gray-white'
|
| 'gray-white'
|
||||||
|
| 'highlight-blue'
|
||||||
|
|
||||||
export function Button(props: {
|
export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
|
@ -56,7 +57,9 @@ export function Button(props: {
|
||||||
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',
|
'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' &&
|
||||||
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
|
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none',
|
||||||
|
color === 'highlight-blue' &&
|
||||||
|
'text-highlight-blue border-none shadow-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite'
|
||||||
import { SearchOptions } from '@algolia/client-search'
|
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 { User } from 'common/user'
|
import { PAST_BETS, User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
ContractHighlightOptions,
|
ContractHighlightOptions,
|
||||||
ContractsGrid,
|
ContractsGrid,
|
||||||
|
@ -39,7 +39,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||||
export const SORTS = [
|
export const SORTS = [
|
||||||
{ label: 'Newest', value: 'newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
{ label: 'Trending', value: 'score' },
|
{ label: 'Trending', value: 'score' },
|
||||||
{ label: 'Most traded', value: 'most-traded' },
|
{ label: `Most ${PAST_BETS}`, value: 'most-traded' },
|
||||||
{ label: '24h volume', value: '24-hour-vol' },
|
{ label: '24h volume', value: '24-hour-vol' },
|
||||||
{ label: '24h change', value: 'prob-change-day' },
|
{ label: '24h change', value: 'prob-change-day' },
|
||||||
{ label: 'Last updated', value: 'last-updated' },
|
{ label: 'Last updated', value: 'last-updated' },
|
||||||
|
@ -77,9 +77,10 @@ export function ContractSearch(props: {
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
hideOrderSelector?: boolean
|
hideOrderSelector?: boolean
|
||||||
cardHideOptions?: {
|
cardUIOptions?: {
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
|
noLinkAvatar?: boolean
|
||||||
}
|
}
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
persistPrefix?: string
|
persistPrefix?: string
|
||||||
|
@ -101,7 +102,7 @@ export function ContractSearch(props: {
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
cardHideOptions,
|
cardUIOptions,
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
persistPrefix,
|
persistPrefix,
|
||||||
|
@ -164,6 +165,7 @@ export function ContractSearch(props: {
|
||||||
numericFilters,
|
numericFilters,
|
||||||
page: requestedPage,
|
page: requestedPage,
|
||||||
hitsPerPage: 20,
|
hitsPerPage: 20,
|
||||||
|
advancedSyntax: true,
|
||||||
})
|
})
|
||||||
// if there's a more recent request, forget about this one
|
// if there's a more recent request, forget about this one
|
||||||
if (id === requestId.current) {
|
if (id === requestId.current) {
|
||||||
|
@ -223,7 +225,7 @@ export function ContractSearch(props: {
|
||||||
showTime={state.showTime ?? undefined}
|
showTime={state.showTime ?? undefined}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
highlightOptions={highlightOptions}
|
highlightOptions={highlightOptions}
|
||||||
cardHideOptions={cardHideOptions}
|
cardUIOptions={cardUIOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -393,9 +395,7 @@ function ContractSearchControls(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
||||||
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
|
||||||
>
|
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -452,7 +452,7 @@ function ContractSearchControls(props: {
|
||||||
selected={pill === 'your-bets'}
|
selected={pill === 'your-bets'}
|
||||||
onSelect={selectPill('your-bets')}
|
onSelect={selectPill('your-bets')}
|
||||||
>
|
>
|
||||||
Your trades
|
Your {PAST_BETS}
|
||||||
</PillButton>
|
</PillButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -81,18 +81,22 @@ export function SelectMarketsModal(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto sm:px-8">
|
<div className="overflow-y-auto px-2 sm:px-8">
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
hideOrderSelector
|
hideOrderSelector
|
||||||
onContractClick={addContract}
|
onContractClick={addContract}
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
cardUIOptions={{
|
||||||
|
hideGroupLink: true,
|
||||||
|
hideQuickBet: true,
|
||||||
|
noLinkAvatar: true,
|
||||||
|
}}
|
||||||
highlightOptions={{
|
highlightOptions={{
|
||||||
contractIds: contracts.map((c) => c.id),
|
contractIds: 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',
|
||||||
}}
|
}}
|
||||||
additionalFilter={{}} /* hide pills */
|
additionalFilter={{}} /* hide pills */
|
||||||
headerClassName="bg-white"
|
headerClassName="bg-white sticky"
|
||||||
{...contractSearchOptions}
|
{...contractSearchOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,6 +42,7 @@ export function ContractCard(props: {
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
trackingPostfix?: string
|
trackingPostfix?: string
|
||||||
|
noLinkAvatar?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
showTime,
|
showTime,
|
||||||
|
@ -51,6 +52,7 @@ export function ContractCard(props: {
|
||||||
hideQuickBet,
|
hideQuickBet,
|
||||||
hideGroupLink,
|
hideGroupLink,
|
||||||
trackingPostfix,
|
trackingPostfix,
|
||||||
|
noLinkAvatar,
|
||||||
} = props
|
} = props
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
const { question, outcomeType } = contract
|
const { question, outcomeType } = contract
|
||||||
|
@ -78,6 +80,7 @@ export function ContractCard(props: {
|
||||||
<AvatarDetails
|
<AvatarDetails
|
||||||
contract={contract}
|
contract={contract}
|
||||||
className={'hidden md:inline-flex'}
|
className={'hidden md:inline-flex'}
|
||||||
|
noLink={noLinkAvatar}
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -142,7 +145,12 @@ export function ContractCard(props: {
|
||||||
showQuickBet ? 'w-[85%]' : 'w-full'
|
showQuickBet ? 'w-[85%]' : 'w-full'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AvatarDetails contract={contract} short={true} className="md:hidden" />
|
<AvatarDetails
|
||||||
|
contract={contract}
|
||||||
|
short={true}
|
||||||
|
className="md:hidden"
|
||||||
|
noLink={noLinkAvatar}
|
||||||
|
/>
|
||||||
<MiscDetails
|
<MiscDetails
|
||||||
contract={contract}
|
contract={contract}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
import {
|
import { ClockIcon } from '@heroicons/react/outline'
|
||||||
ClockIcon,
|
|
||||||
DatabaseIcon,
|
|
||||||
PencilIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
} from '@heroicons/react/outline'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import { fromNow } from 'web/lib/util/time'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ContractInfoDialog } from './contract-info-dialog'
|
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { UserFollowButton } 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 } from 'web/hooks/use-user'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
import { exhibitExts } from 'common/util/parse'
|
||||||
|
@ -33,7 +27,11 @@ import { contractMetrics } from 'common/contract-details'
|
||||||
import { UserLink } from 'web/components/user-link'
|
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 { useWindowSize } from 'web/hooks/use-window-size'
|
import { ExtraContractActionsRow } from './extra-contract-actions-row'
|
||||||
|
import { PlusCircleIcon } from '@heroicons/react/solid'
|
||||||
|
import { GroupLink } from 'common/group'
|
||||||
|
import { Subtitle } from '../subtitle'
|
||||||
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -86,8 +84,9 @@ export function AvatarDetails(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
className?: string
|
className?: string
|
||||||
short?: boolean
|
short?: boolean
|
||||||
|
noLink?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, short, className } = props
|
const { contract, short, className, noLink } = props
|
||||||
const { creatorName, creatorUsername, creatorAvatarUrl } = contract
|
const { creatorName, creatorUsername, creatorAvatarUrl } = contract
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -98,8 +97,14 @@ export function AvatarDetails(props: {
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
avatarUrl={creatorAvatarUrl}
|
avatarUrl={creatorAvatarUrl}
|
||||||
size={6}
|
size={6}
|
||||||
|
noLink={noLink}
|
||||||
|
/>
|
||||||
|
<UserLink
|
||||||
|
name={creatorName}
|
||||||
|
username={creatorUsername}
|
||||||
|
short={short}
|
||||||
|
noLink={noLink}
|
||||||
/>
|
/>
|
||||||
<UserLink name={creatorName} username={creatorUsername} short={short} />
|
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -109,85 +114,146 @@ export function ContractDetails(props: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, disabled } = props
|
const { contract, disabled } = props
|
||||||
const {
|
const isMobile = useIsMobile()
|
||||||
closeTime,
|
|
||||||
creatorName,
|
|
||||||
creatorUsername,
|
|
||||||
creatorId,
|
|
||||||
creatorAvatarUrl,
|
|
||||||
resolutionTime,
|
|
||||||
} = contract
|
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
|
||||||
const user = useUser()
|
|
||||||
const isCreator = user?.id === creatorId
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
const isMobile = (width ?? 0) < 600
|
|
||||||
const groupToDisplay = getGroupLinkToDisplay(contract)
|
|
||||||
const groupInfo = groupToDisplay ? (
|
|
||||||
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
|
|
||||||
<a
|
|
||||||
className={clsx(
|
|
||||||
linkClass,
|
|
||||||
'flex flex-row items-center truncate pr-0 sm:pr-2',
|
|
||||||
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
|
||||||
<span className="items-center truncate">{groupToDisplay.name}</span>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size={'xs'}
|
|
||||||
className={'max-w-[200px] pr-2'}
|
|
||||||
color={'gray-white'}
|
|
||||||
onClick={() => !groupToDisplay && setOpen(true)}
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
|
||||||
<span className="truncate">No Group</span>
|
|
||||||
</Row>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
|
<Col>
|
||||||
<Row className="items-center gap-2">
|
<Row className="justify-between">
|
||||||
|
<MarketSubheader contract={contract} disabled={disabled} />
|
||||||
|
<div className="mt-0">
|
||||||
|
<ExtraContractActionsRow contract={contract} />
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/* GROUPS */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<MarketGroups
|
||||||
|
contract={contract}
|
||||||
|
isMobile={isMobile}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketSubheader(props: {
|
||||||
|
contract: Contract
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const { contract, disabled } = props
|
||||||
|
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
|
||||||
|
const { resolvedDate } = contractMetrics(contract)
|
||||||
|
const user = useUser()
|
||||||
|
const isCreator = user?.id === creatorId
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
<Avatar
|
<Avatar
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
avatarUrl={creatorAvatarUrl}
|
avatarUrl={creatorAvatarUrl}
|
||||||
noLink={disabled}
|
noLink={disabled}
|
||||||
size={6}
|
size={9}
|
||||||
|
className="mr-1.5"
|
||||||
/>
|
/>
|
||||||
|
{!disabled && (
|
||||||
|
<div className="absolute mt-3 ml-[11px]">
|
||||||
|
<MiniUserFollowButton userId={creatorId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm">
|
||||||
|
<Row className="w-full justify-between ">
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
creatorName
|
creatorName
|
||||||
) : (
|
) : (
|
||||||
<UserLink
|
<UserLink
|
||||||
className="whitespace-nowrap"
|
className="my-auto whitespace-nowrap"
|
||||||
name={creatorName}
|
name={creatorName}
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
short={isMobile}
|
short={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs">
|
||||||
|
<CloseOrResolveTime
|
||||||
|
contract={contract}
|
||||||
|
resolvedDate={resolvedDate}
|
||||||
|
isCreator={isCreator}
|
||||||
|
/>
|
||||||
|
{!isMobile && (
|
||||||
|
<MarketGroups
|
||||||
|
contract={contract}
|
||||||
|
isMobile={isMobile}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseOrResolveTime(props: {
|
||||||
|
contract: Contract
|
||||||
|
resolvedDate: any
|
||||||
|
isCreator: boolean
|
||||||
|
}) {
|
||||||
|
const { contract, resolvedDate, isCreator } = props
|
||||||
|
const { resolutionTime, closeTime } = contract
|
||||||
|
if (!!closeTime || !!resolvedDate) {
|
||||||
|
return (
|
||||||
|
<Row className="select-none items-center gap-1">
|
||||||
|
{resolvedDate && resolutionTime ? (
|
||||||
|
<>
|
||||||
|
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
|
||||||
<Row>
|
<Row>
|
||||||
{disabled ? (
|
<div>resolved </div>
|
||||||
groupInfo
|
{resolvedDate}
|
||||||
) : !groupToDisplay && !user ? (
|
</Row>
|
||||||
<div />
|
</DateTimeTooltip>
|
||||||
) : (
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!resolvedDate && closeTime && (
|
||||||
<Row>
|
<Row>
|
||||||
{groupInfo}
|
{dayjs().isBefore(closeTime) && <div>closes </div>}
|
||||||
{user && groupToDisplay && (
|
{!dayjs().isBefore(closeTime) && <div>closed </div>}
|
||||||
<Button
|
<EditableCloseDate
|
||||||
size={'xs'}
|
closeTime={closeTime}
|
||||||
color={'gray-white'}
|
contract={contract}
|
||||||
|
isCreator={isCreator ?? false}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
} else return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketGroups(props: {
|
||||||
|
contract: Contract
|
||||||
|
isMobile: boolean | undefined
|
||||||
|
disabled: boolean | undefined
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const user = useUser()
|
||||||
|
const { contract, isMobile, disabled } = props
|
||||||
|
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className="align-middle">
|
||||||
|
<GroupDisplay groupToDisplay={groupToDisplay} isMobile={isMobile} />
|
||||||
|
{!disabled && (
|
||||||
|
<Row>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
className="text-greyscale-4 hover:text-greyscale-3"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
|
<PlusCircleIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
@ -201,45 +267,7 @@ export function ContractDetails(props: {
|
||||||
<ContractGroupsList contract={contract} user={user} />
|
<ContractGroupsList contract={contract} user={user} />
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{(!!closeTime || !!resolvedDate) && (
|
|
||||||
<Row className="hidden items-center gap-1 md:inline-flex">
|
|
||||||
{resolvedDate && resolutionTime ? (
|
|
||||||
<>
|
|
||||||
<ClockIcon className="h-5 w-5" />
|
|
||||||
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
|
|
||||||
{resolvedDate}
|
|
||||||
</DateTimeTooltip>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!resolvedDate && closeTime && user && (
|
|
||||||
<>
|
|
||||||
<ClockIcon className="h-5 w-5" />
|
|
||||||
<EditableCloseDate
|
|
||||||
closeTime={closeTime}
|
|
||||||
contract={contract}
|
|
||||||
isCreator={isCreator ?? false}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{user && (
|
|
||||||
<>
|
|
||||||
<Row className="hidden items-center gap-1 md:inline-flex">
|
|
||||||
<DatabaseIcon className="h-5 w-5" />
|
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
|
||||||
</Row>
|
|
||||||
{!disabled && (
|
|
||||||
<ContractInfoDialog
|
|
||||||
contract={contract}
|
|
||||||
className={'hidden md:inline-flex'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -280,12 +308,12 @@ export function ExtraMobileContractDetails(props: {
|
||||||
!resolvedDate &&
|
!resolvedDate &&
|
||||||
closeTime && (
|
closeTime && (
|
||||||
<Col className={'items-center text-sm text-gray-500'}>
|
<Col className={'items-center text-sm text-gray-500'}>
|
||||||
|
<Row className={'text-gray-400'}>Closes </Row>
|
||||||
<EditableCloseDate
|
<EditableCloseDate
|
||||||
closeTime={closeTime}
|
closeTime={closeTime}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isCreator={creatorId === user?.id}
|
isCreator={creatorId === user?.id}
|
||||||
/>
|
/>
|
||||||
<Row className={'text-gray-400'}>Ends</Row>
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
@ -305,6 +333,45 @@ export function ExtraMobileContractDetails(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupDisplay(props: {
|
||||||
|
groupToDisplay?: GroupLink | null
|
||||||
|
isMobile?: boolean
|
||||||
|
}) {
|
||||||
|
const { groupToDisplay, isMobile } = props
|
||||||
|
if (groupToDisplay) {
|
||||||
|
return (
|
||||||
|
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
|
||||||
|
<a
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-row items-center truncate pr-1',
|
||||||
|
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-greyscale-4 hover:bg-greyscale-3 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs">
|
||||||
|
{groupToDisplay.name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'cursor-default select-none items-center truncate pr-1',
|
||||||
|
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'bg-greyscale-4 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
No Group
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EditableCloseDate(props: {
|
function EditableCloseDate(props: {
|
||||||
closeTime: number
|
closeTime: number
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -356,11 +423,18 @@ function EditableCloseDate(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditingCloseTime ? (
|
<Modal
|
||||||
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
|
size="sm"
|
||||||
|
open={isEditingCloseTime}
|
||||||
|
setOpen={setIsEditingCloseTime}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Col className="rounded bg-white px-8 pb-8">
|
||||||
|
<Subtitle text="Edit Close Date" />
|
||||||
|
<Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input input-bordered shrink-0"
|
className="input input-bordered w-full shrink-0 sm:w-fit"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseDate(e.target.value)}
|
onChange={(e) => setCloseDate(e.target.value)}
|
||||||
min={Date.now()}
|
min={Date.now()}
|
||||||
|
@ -368,17 +442,23 @@ function EditableCloseDate(props: {
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
className="input input-bordered shrink-0"
|
className="input input-bordered w-full shrink-0 sm:w-max"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||||
min="00:00"
|
min="00:00"
|
||||||
value={closeHoursMinutes}
|
value={closeHoursMinutes}
|
||||||
/>
|
/>
|
||||||
<Button size={'xs'} color={'blue'} onClick={onSave}>
|
</Row>
|
||||||
|
<Button
|
||||||
|
className="mt-2"
|
||||||
|
size={'xs'}
|
||||||
|
color={'indigo'}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Col>
|
||||||
) : (
|
</Modal>
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
||||||
time={closeTime}
|
time={closeTime}
|
||||||
|
@ -396,7 +476,6 @@ function EditableCloseDate(props: {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
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'
|
||||||
|
@ -18,6 +19,8 @@ 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 { Button } from '../button'
|
||||||
|
|
||||||
export const contractDetailsButtonClassName =
|
export const contractDetailsButtonClassName =
|
||||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||||
|
@ -37,10 +40,16 @@ export function ContractInfoDialog(props: {
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
const {
|
||||||
contract
|
createdTime,
|
||||||
|
closeTime,
|
||||||
|
resolutionTime,
|
||||||
|
uniqueBettorCount,
|
||||||
|
mechanism,
|
||||||
|
outcomeType,
|
||||||
|
id,
|
||||||
|
} = contract
|
||||||
|
|
||||||
const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
|
|
||||||
const typeDisplay =
|
const typeDisplay =
|
||||||
outcomeType === 'BINARY'
|
outcomeType === 'BINARY'
|
||||||
? 'YES / NO'
|
? 'YES / NO'
|
||||||
|
@ -67,19 +76,21 @@ export function ContractInfoDialog(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="gray-white"
|
||||||
className={clsx(contractDetailsButtonClassName, className)}
|
className={clsx(contractDetailsButtonClassName, className)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon
|
<DotsHorizontalIcon
|
||||||
className={clsx('h-6 w-6 flex-shrink-0')}
|
className={clsx('h-5 w-5 flex-shrink-0')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
<Col className="gap-4 rounded bg-white p-6">
|
<Col className="gap-4 rounded bg-white p-6">
|
||||||
<Title className="!mt-0 !mb-0" text="Market info" />
|
<Title className="!mt-0 !mb-0" text="This Market" />
|
||||||
|
|
||||||
<table className="table-compact table-zebra table w-full text-gray-500">
|
<table className="table-compact table-zebra table w-full text-gray-500">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -129,14 +140,9 @@ export function ContractInfoDialog(props: {
|
||||||
<td>{formatMoney(contract.volume)}</td>
|
<td>{formatMoney(contract.volume)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* <tr>
|
|
||||||
<td>Creator earnings</td>
|
|
||||||
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
|
||||||
</tr> */}
|
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Traders</td>
|
<td>{capitalize(BETTORS)}</td>
|
||||||
<td>{bettorsCount}</td>
|
<td>{uniqueBettorCount ?? '0'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { FeedComment } from '../feed/feed-comments'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Leaderboard } from '../leaderboard'
|
import { Leaderboard } from '../leaderboard'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
|
import { BETTORS } from 'common/user'
|
||||||
|
|
||||||
export function ContractLeaderboard(props: {
|
export function ContractLeaderboard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -48,7 +49,7 @@ export function ContractLeaderboard(props: {
|
||||||
|
|
||||||
return users && users.length > 0 ? (
|
return users && users.length > 0 ? (
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
title="🏅 Top traders"
|
title={`🏅 Top ${BETTORS}`}
|
||||||
users={users || []}
|
users={users || []}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
|
|
@ -25,11 +25,11 @@ import {
|
||||||
NumericContract,
|
NumericContract,
|
||||||
PseudoNumericContract,
|
PseudoNumericContract,
|
||||||
} from 'common/contract'
|
} from 'common/contract'
|
||||||
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
|
import { ContractDetails } from './contract-details'
|
||||||
import { NumericGraph } from './numeric-graph'
|
import { NumericGraph } from './numeric-graph'
|
||||||
|
|
||||||
const OverviewQuestion = (props: { text: string }) => (
|
const OverviewQuestion = (props: { text: string }) => (
|
||||||
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} />
|
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
|
||||||
)
|
)
|
||||||
|
|
||||||
const BetWidget = (props: { contract: CPMMContract }) => {
|
const BetWidget = (props: { contract: CPMMContract }) => {
|
||||||
|
@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
||||||
const { contract, bets } = props
|
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-1 px-2">
|
||||||
<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} />
|
||||||
|
@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
||||||
</Row>
|
</Row>
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
<ExtraMobileContractDetails contract={contract} />
|
|
||||||
{tradingAllowed(contract) && (
|
{tradingAllowed(contract) && (
|
||||||
<BetWidget contract={contract as CPMMBinaryContract} />
|
<BetWidget contract={contract as CPMMBinaryContract} />
|
||||||
)}
|
)}
|
||||||
|
@ -113,10 +112,6 @@ const ChoiceOverview = (props: {
|
||||||
</Col>
|
</Col>
|
||||||
<Col className={'mb-1 gap-y-2'}>
|
<Col className={'mb-1 gap-y-2'}>
|
||||||
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
|
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
|
||||||
<ExtraMobileContractDetails
|
|
||||||
contract={contract}
|
|
||||||
forceShowVolume={true}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: {
|
||||||
</Row>
|
</Row>
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
<ExtraMobileContractDetails contract={contract} />
|
|
||||||
{tradingAllowed(contract) && <BetWidget contract={contract} />}
|
{tradingAllowed(contract) && <BetWidget contract={contract} />}
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||||
import { ContractComment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { User } from 'common/user'
|
import { PAST_BETS, User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
ContractCommentsActivity,
|
ContractCommentsActivity,
|
||||||
ContractBetsActivity,
|
ContractBetsActivity,
|
||||||
|
@ -18,6 +18,12 @@ import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||||
import BetButton from '../bet-button'
|
import BetButton from '../bet-button'
|
||||||
|
import { capitalize } from 'lodash'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from 'common/antes'
|
||||||
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||||
|
|
||||||
export function ContractTabs(props: {
|
export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -28,6 +34,7 @@ export function ContractTabs(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, bets, tips } = props
|
const { contract, user, bets, tips } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const lps = useLiquidity(contract.id)
|
const lps = useLiquidity(contract.id)
|
||||||
|
|
||||||
|
@ -36,13 +43,19 @@ export function ContractTabs(props: {
|
||||||
const visibleBets = bets.filter(
|
const visibleBets = bets.filter(
|
||||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||||
)
|
)
|
||||||
const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
|
const visibleLps = (lps ?? []).filter(
|
||||||
|
(l) =>
|
||||||
|
!l.isAnte &&
|
||||||
|
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
|
||||||
|
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
|
||||||
|
l.amount > 0
|
||||||
|
)
|
||||||
|
|
||||||
// Load comments here, so the badge count will be correct
|
// Load comments here, so the badge count will be correct
|
||||||
const updatedComments = useComments(contract.id)
|
const updatedComments = useComments(contract.id)
|
||||||
const comments = updatedComments ?? props.comments
|
const comments = updatedComments ?? props.comments
|
||||||
|
|
||||||
const betActivity = visibleLps && (
|
const betActivity = lps != null && (
|
||||||
<ContractBetsActivity
|
<ContractBetsActivity
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={visibleBets}
|
bets={visibleBets}
|
||||||
|
@ -114,13 +127,18 @@ export function ContractTabs(props: {
|
||||||
badge: `${comments.length}`,
|
badge: `${comments.length}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Trades',
|
title: capitalize(PAST_BETS),
|
||||||
content: betActivity,
|
content: betActivity,
|
||||||
badge: `${visibleBets.length}`,
|
badge: `${visibleBets.length + visibleLps.length}`,
|
||||||
},
|
},
|
||||||
...(!user || !userBets?.length
|
...(!user || !userBets?.length
|
||||||
? []
|
? []
|
||||||
: [{ title: 'Your trades', content: yourTrades }]),
|
: [
|
||||||
|
{
|
||||||
|
title: isMobile ? `You` : `Your ${PAST_BETS}`,
|
||||||
|
content: yourTrades,
|
||||||
|
},
|
||||||
|
]),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{!user ? (
|
{!user ? (
|
||||||
|
|
|
@ -21,9 +21,10 @@ export function ContractsGrid(props: {
|
||||||
loadMore?: () => void
|
loadMore?: () => void
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
cardHideOptions?: {
|
cardUIOptions?: {
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
|
noLinkAvatar?: boolean
|
||||||
}
|
}
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
trackingPostfix?: string
|
trackingPostfix?: string
|
||||||
|
@ -34,11 +35,11 @@ export function ContractsGrid(props: {
|
||||||
showTime,
|
showTime,
|
||||||
loadMore,
|
loadMore,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
cardHideOptions,
|
cardUIOptions,
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
trackingPostfix,
|
trackingPostfix,
|
||||||
} = props
|
} = props
|
||||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
|
||||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||||
const onVisibilityUpdated = useCallback(
|
const onVisibilityUpdated = useCallback(
|
||||||
(visible) => {
|
(visible) => {
|
||||||
|
@ -80,6 +81,7 @@ export function ContractsGrid(props: {
|
||||||
onClick={
|
onClick={
|
||||||
onContractClick ? () => onContractClick(contract) : undefined
|
onContractClick ? () => onContractClick(contract) : undefined
|
||||||
}
|
}
|
||||||
|
noLinkAvatar={noLinkAvatar}
|
||||||
hideQuickBet={hideQuickBet}
|
hideQuickBet={hideQuickBet}
|
||||||
hideGroupLink={hideGroupLink}
|
hideGroupLink={hideGroupLink}
|
||||||
trackingPostfix={trackingPostfix}
|
trackingPostfix={trackingPostfix}
|
||||||
|
@ -108,6 +110,7 @@ export function CreatorContractsList(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
|
headerClassName="sticky"
|
||||||
user={user}
|
user={user}
|
||||||
defaultSort="newest"
|
defaultSort="newest"
|
||||||
defaultFilter="all"
|
defaultFilter="all"
|
||||||
|
|
|
@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||||
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
|
||||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
|
||||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
|
|
||||||
|
|
||||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { outcomeType, resolution } = contract
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [isShareOpen, setShareOpen] = useState(false)
|
const [isShareOpen, setShareOpen] = useState(false)
|
||||||
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
|
|
||||||
useState(false)
|
|
||||||
const showChallenge =
|
|
||||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
|
<Row>
|
||||||
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
|
{user?.id !== contract.creatorId && (
|
||||||
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="sm"
|
||||||
color="gray-white"
|
color="gray-white"
|
||||||
className={'flex'}
|
className={'flex'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShareOpen(true)
|
setShareOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Col className={'items-center sm:flex-row'}>
|
<Row>
|
||||||
<ShareIcon
|
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
|
||||||
className={clsx('h-[24px] w-5 sm:mr-2')}
|
</Row>
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>Share</span>
|
|
||||||
</Col>
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={isShareOpen}
|
isOpen={isShareOpen}
|
||||||
setOpen={setShareOpen}
|
setOpen={setShareOpen}
|
||||||
|
@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Col className={'justify-center'}>
|
||||||
{showChallenge && (
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
color="gray-white"
|
|
||||||
className="max-w-xs self-center"
|
|
||||||
onClick={withTracking(
|
|
||||||
() => setOpenCreateChallengeModal(true),
|
|
||||||
'click challenge button'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Col className="items-center sm:flex-row">
|
|
||||||
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
|
|
||||||
<span>Challenge</span>
|
|
||||||
</Col>
|
|
||||||
<CreateChallengeModal
|
|
||||||
isOpen={openCreateChallengeModal}
|
|
||||||
setOpen={setOpenCreateChallengeModal}
|
|
||||||
user={user}
|
|
||||||
contract={contract}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FollowMarketButton contract={contract} user={user} />
|
|
||||||
{user?.id !== contract.creatorId && (
|
|
||||||
<LikeMarketButton contract={contract} user={user} />
|
|
||||||
)}
|
|
||||||
<Col className={'justify-center md:hidden'}>
|
|
||||||
<ContractInfoDialog contract={contract} />
|
<ContractInfoDialog contract={contract} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -38,15 +38,16 @@ export function LikeMarketButton(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size={'lg'}
|
size={'sm'}
|
||||||
className={'max-w-xs self-center'}
|
className={'max-w-xs self-center'}
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
onClick={onLike}
|
onClick={onLike}
|
||||||
>
|
>
|
||||||
<Col className={'items-center sm:flex-row'}>
|
<Col className={'relative items-center sm:flex-row'}>
|
||||||
<HeartIcon
|
<HeartIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-[24px] w-5 sm:mr-2',
|
'h-5 w-5 sm:h-6 sm:w-6',
|
||||||
|
totalTipped > 0 ? 'mr-2' : '',
|
||||||
user &&
|
user &&
|
||||||
(userLikedContractIds?.includes(contract.id) ||
|
(userLikedContractIds?.includes(contract.id) ||
|
||||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||||
|
@ -54,7 +55,18 @@ export function LikeMarketButton(props: {
|
||||||
: ''
|
: ''
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''}
|
{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>
|
</Col>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,21 +18,22 @@ export const WatchMarketModal = (props: {
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You'll receive notifications on markets by betting, commenting, or
|
Watching a market means you'll receive notifications from activity
|
||||||
clicking the
|
on it. You automatically start watching a market if you comment on
|
||||||
|
it, bet on it, or click the
|
||||||
<EyeIcon
|
<EyeIcon
|
||||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
️ button on them.
|
️ button.
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-indigo-700'}>
|
<span className={'text-indigo-700'}>
|
||||||
• What types of notifications will I receive?
|
• What types of notifications will I receive?
|
||||||
</span>
|
</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You'll receive notifications for new comments, answers, and updates
|
New comments, answers, and updates to the question. See the
|
||||||
to the question. See the notifications settings pages to customize
|
notifications settings pages to customize which types of
|
||||||
which types of notifications you receive on watched markets.
|
notifications you receive on watched markets.
|
||||||
</span>
|
</span>
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||||
import { DisplayMention } from './editor/mention'
|
import { DisplayMention } from './editor/mention'
|
||||||
|
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
|
||||||
|
import { DisplayContractMention } from './editor/contract-mention'
|
||||||
import Iframe from 'common/util/tiptap-iframe'
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
import TiptapTweet from './editor/tiptap-tweet'
|
import TiptapTweet from './editor/tiptap-tweet'
|
||||||
import { EmbedModal } from './editor/embed-modal'
|
import { EmbedModal } from './editor/embed-modal'
|
||||||
|
@ -97,7 +99,12 @@ export function useTextEditor(props: {
|
||||||
CharacterCount.configure({ limit: max }),
|
CharacterCount.configure({ limit: max }),
|
||||||
simple ? DisplayImage : Image,
|
simple ? DisplayImage : Image,
|
||||||
DisplayLink,
|
DisplayLink,
|
||||||
DisplayMention.configure({ suggestion: mentionSuggestion }),
|
DisplayMention.configure({
|
||||||
|
suggestion: mentionSuggestion,
|
||||||
|
}),
|
||||||
|
DisplayContractMention.configure({
|
||||||
|
suggestion: contractMentionSuggestion,
|
||||||
|
}),
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
],
|
],
|
||||||
|
@ -316,13 +323,21 @@ export function RichContent(props: {
|
||||||
smallImage ? DisplayImage : Image,
|
smallImage ? DisplayImage : Image,
|
||||||
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
||||||
DisplayMention,
|
DisplayMention,
|
||||||
|
DisplayContractMention.configure({
|
||||||
|
// Needed to set a different PluginKey for Prosemirror
|
||||||
|
suggestion: contractMentionSuggestion,
|
||||||
|
}),
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editable: false,
|
editable: false,
|
||||||
})
|
})
|
||||||
useEffect(() => void editor?.commands?.setContent(content), [editor, content])
|
useEffect(
|
||||||
|
// Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769
|
||||||
|
() => void !editor?.isDestroyed && editor?.commands?.setContent(content),
|
||||||
|
[editor, content]
|
||||||
|
)
|
||||||
|
|
||||||
return <EditorContent className={className} editor={editor} />
|
return <EditorContent className={className} editor={editor} />
|
||||||
}
|
}
|
||||||
|
|
68
web/components/editor/contract-mention-list.tsx
Normal file
68
web/components/editor/contract-mention-list.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { SuggestionProps } from '@tiptap/suggestion'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
|
import { Avatar } from '../avatar'
|
||||||
|
|
||||||
|
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||||
|
const M = forwardRef((props: SuggestionProps<Contract>, ref) => {
|
||||||
|
const { items: contracts, command } = props
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
useEffect(() => setSelectedIndex(0), [contracts])
|
||||||
|
|
||||||
|
const submitUser = (index: number) => {
|
||||||
|
const contract = contracts[index]
|
||||||
|
if (contract)
|
||||||
|
command({ id: contract.id, label: contractPath(contract) } as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () =>
|
||||||
|
setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length)
|
||||||
|
const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length)
|
||||||
|
const onEnter = () => submitUser(selectedIndex)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: any) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
onUp()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
onDown()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
onEnter()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
{!contracts.length ? (
|
||||||
|
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||||
|
) : (
|
||||||
|
contracts.map((contract, i) => (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200',
|
||||||
|
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||||
|
)}
|
||||||
|
onClick={() => submitUser(i)}
|
||||||
|
key={contract.id}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" />
|
||||||
|
{contract.question}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Just to keep the formatting pretty
|
||||||
|
export { M as MentionList }
|
27
web/components/editor/contract-mention-suggestion.ts
Normal file
27
web/components/editor/contract-mention-suggestion.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||||
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
import { orderBy } from 'lodash'
|
||||||
|
import { getCachedContracts } from 'web/hooks/use-contracts'
|
||||||
|
import { MentionList } from './contract-mention-list'
|
||||||
|
import { PluginKey } from 'prosemirror-state'
|
||||||
|
import { makeMentionRender } from './mention-suggestion'
|
||||||
|
|
||||||
|
type Suggestion = MentionOptions['suggestion']
|
||||||
|
|
||||||
|
const beginsWith = (text: string, query: string) =>
|
||||||
|
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||||
|
|
||||||
|
export const contractMentionSuggestion: Suggestion = {
|
||||||
|
char: '%',
|
||||||
|
allowSpaces: true,
|
||||||
|
pluginKey: new PluginKey('contract-mention'),
|
||||||
|
items: async ({ query }) =>
|
||||||
|
orderBy(
|
||||||
|
(await getCachedContracts()).filter((c) =>
|
||||||
|
searchInAny(query, c.question)
|
||||||
|
),
|
||||||
|
[(c) => [c.question].some((s) => beginsWith(s, query))],
|
||||||
|
['desc', 'desc']
|
||||||
|
).slice(0, 5),
|
||||||
|
render: makeMentionRender(MentionList),
|
||||||
|
}
|
42
web/components/editor/contract-mention.tsx
Normal file
42
web/components/editor/contract-mention.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import Mention from '@tiptap/extension-mention'
|
||||||
|
import {
|
||||||
|
mergeAttributes,
|
||||||
|
NodeViewWrapper,
|
||||||
|
ReactNodeViewRenderer,
|
||||||
|
} from '@tiptap/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useContract } from 'web/hooks/use-contract'
|
||||||
|
import { ContractCard } from '../contract/contract-card'
|
||||||
|
|
||||||
|
const name = 'contract-mention-component'
|
||||||
|
|
||||||
|
const ContractMentionComponent = (props: any) => {
|
||||||
|
const contract = useContract(props.node.attrs.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className={clsx(name, 'not-prose')}>
|
||||||
|
{contract && (
|
||||||
|
<ContractCard
|
||||||
|
contract={contract}
|
||||||
|
className="my-2 w-full border border-gray-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention extension that renders React. See:
|
||||||
|
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
|
||||||
|
* https://tiptap.dev/guide/node-views/react#render-a-react-component
|
||||||
|
*/
|
||||||
|
export const DisplayContractMention = Mention.extend({
|
||||||
|
name: 'contract-mention',
|
||||||
|
parseHTML: () => [{ tag: name }],
|
||||||
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
|
addNodeView: () =>
|
||||||
|
ReactNodeViewRenderer(ContractMentionComponent, {
|
||||||
|
// On desktop, render cards below half-width so you can stack two
|
||||||
|
className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1',
|
||||||
|
}),
|
||||||
|
})
|
|
@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
|
||||||
import tippy from 'tippy.js'
|
import tippy from 'tippy.js'
|
||||||
import { getCachedUsers } from 'web/hooks/use-users'
|
import { getCachedUsers } from 'web/hooks/use-users'
|
||||||
import { MentionList } from './mention-list'
|
import { MentionList } from './mention-list'
|
||||||
|
type Render = Suggestion['render']
|
||||||
|
|
||||||
type Suggestion = MentionOptions['suggestion']
|
type Suggestion = MentionOptions['suggestion']
|
||||||
|
|
||||||
|
@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
|
||||||
],
|
],
|
||||||
['desc', 'desc']
|
['desc', 'desc']
|
||||||
).slice(0, 5),
|
).slice(0, 5),
|
||||||
render: () => {
|
render: makeMentionRender(MentionList),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMentionRender(mentionList: any): Render {
|
||||||
|
return () => {
|
||||||
let component: ReactRenderer
|
let component: ReactRenderer
|
||||||
let popup: ReturnType<typeof tippy>
|
let popup: ReturnType<typeof tippy>
|
||||||
return {
|
return {
|
||||||
onStart: (props) => {
|
onStart: (props) => {
|
||||||
component = new ReactRenderer(MentionList, {
|
component = new ReactRenderer(mentionList, {
|
||||||
props,
|
props,
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
})
|
})
|
||||||
|
@ -59,9 +64,15 @@ export const mentionSuggestion: Suggestion = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onKeyDown(props) {
|
onKeyDown(props) {
|
||||||
if (props.event.key === 'Escape') {
|
if (props.event.key)
|
||||||
popup?.[0].hide()
|
if (
|
||||||
return true
|
props.event.key === 'Escape' ||
|
||||||
|
// Also break out of the mention if the tooltip isn't visible
|
||||||
|
(props.event.key === 'Enter' && !popup?.[0].state.isShown)
|
||||||
|
) {
|
||||||
|
popup?.[0].destroy()
|
||||||
|
component?.destroy()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return (component?.ref as any)?.onKeyDown(props)
|
return (component?.ref as any)?.onKeyDown(props)
|
||||||
},
|
},
|
||||||
|
@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
|
||||||
component?.destroy()
|
component?.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: {
|
||||||
const tweetId = props.node.attrs.tweetId.slice(1)
|
const tweetId = props.node.attrs.tweetId.slice(1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="tiptap-tweet">
|
<NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto">
|
||||||
<TwitterTweetEmbed tweetId={tweetId} />
|
<TwitterTweetEmbed tweetId={tweetId} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Contract, FreeResponseContract } from 'common/contract'
|
import { Contract, FreeResponseContract } from 'common/contract'
|
||||||
import { ContractComment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
import { getOutcomeProbability } from 'common/calculate'
|
||||||
|
import { Pagination } from 'web/components/pagination'
|
||||||
import { FeedBet } from './feed-bets'
|
import { FeedBet } from './feed-bets'
|
||||||
import { FeedLiquidity } from './feed-liquidity'
|
import { FeedLiquidity } from './feed-liquidity'
|
||||||
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
|
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
|
||||||
|
@ -19,6 +21,10 @@ export function ContractBetsActivity(props: {
|
||||||
lps: LiquidityProvision[]
|
lps: LiquidityProvision[]
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, lps } = props
|
const { contract, bets, lps } = props
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const ITEMS_PER_PAGE = 50
|
||||||
|
const start = page * ITEMS_PER_PAGE
|
||||||
|
const end = start + ITEMS_PER_PAGE
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
...bets.map((bet) => ({
|
...bets.map((bet) => ({
|
||||||
|
@ -33,17 +39,18 @@ export function ContractBetsActivity(props: {
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
const sortedItems = sortBy(items, (item) =>
|
const pageItems = sortBy(items, (item) =>
|
||||||
item.type === 'bet'
|
item.type === 'bet'
|
||||||
? -item.bet.createdTime
|
? -item.bet.createdTime
|
||||||
: item.type === 'liquidity'
|
: item.type === 'liquidity'
|
||||||
? -item.lp.createdTime
|
? -item.lp.createdTime
|
||||||
: undefined
|
: undefined
|
||||||
)
|
).slice(start, end)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-4">
|
<>
|
||||||
{sortedItems.map((item) =>
|
<Col className="mb-4 gap-4">
|
||||||
|
{pageItems.map((item) =>
|
||||||
item.type === 'bet' ? (
|
item.type === 'bet' ? (
|
||||||
<FeedBet key={item.id} contract={contract} bet={item.bet} />
|
<FeedBet key={item.id} contract={contract} bet={item.bet} />
|
||||||
) : (
|
) : (
|
||||||
|
@ -51,6 +58,16 @@ export function ContractBetsActivity(props: {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
itemsPerPage={50}
|
||||||
|
totalItems={items.length}
|
||||||
|
setPage={setPage}
|
||||||
|
scrollToTop
|
||||||
|
nextTitle={'Older'}
|
||||||
|
prevTitle={'Newer'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||||
import { Challenge } from 'common/challenge'
|
import { Challenge } from 'common/challenge'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
import { BETTOR } from 'common/user'
|
||||||
|
|
||||||
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
||||||
const { contract, bet } = props
|
const { contract, bet } = props
|
||||||
|
@ -94,7 +95,7 @@ export function BetStatusText(props: {
|
||||||
{!hideUser ? (
|
{!hideUser ? (
|
||||||
<UserLink name={bet.userName} username={bet.userUsername} />
|
<UserLink name={bet.userName} username={bet.userUsername} />
|
||||||
) : (
|
) : (
|
||||||
<span>{self?.id === bet.userId ? 'You' : 'A trader'}</span>
|
<span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
{outOfTotalAmount}
|
{outOfTotalAmount}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { ContractComment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { User } from 'common/user'
|
import { PRESENT_BET, User } from 'common/user'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
|
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
|
||||||
|
@ -255,7 +255,7 @@ function CommentStatus(props: {
|
||||||
const { contract, outcome, prob } = props
|
const { contract, outcome, prob } = props
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{' betting '}
|
{` ${PRESENT_BET}ing `}
|
||||||
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
|
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
|
||||||
{prob && ' at ' + Math.round(prob * 100) + '%'}
|
{prob && ' at ' + Math.round(prob * 100) + '%'}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { User } from 'common/user'
|
import { BETTOR, User } from 'common/user'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
|
@ -9,17 +9,13 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
import {
|
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
} from 'common/antes'
|
|
||||||
|
|
||||||
export function FeedLiquidity(props: {
|
export function FeedLiquidity(props: {
|
||||||
className?: string
|
className?: string
|
||||||
liquidity: LiquidityProvision
|
liquidity: LiquidityProvision
|
||||||
}) {
|
}) {
|
||||||
const { liquidity } = props
|
const { liquidity } = props
|
||||||
const { userId, createdTime, isAnte } = liquidity
|
const { userId, createdTime } = liquidity
|
||||||
|
|
||||||
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
@ -28,13 +24,6 @@ export function FeedLiquidity(props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
|
||||||
if (
|
|
||||||
isAnte ||
|
|
||||||
userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
|
||||||
userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
)
|
|
||||||
return <></>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2 pt-3">
|
<Row className="items-center gap-2 pt-3">
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
|
@ -74,7 +63,7 @@ export function LiquidityStatusText(props: {
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
<UserLink name={bettor.name} username={bettor.username} />
|
<UserLink name={bettor.name} username={bettor.username} />
|
||||||
) : (
|
) : (
|
||||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
<span>{isSelf ? 'You' : `A ${BETTOR}`}</span>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{bought} a subsidy of {money}
|
{bought} a subsidy of {money}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { follow, unfollow } from 'web/lib/firebase/users'
|
import { follow, unfollow } from 'web/lib/firebase/users'
|
||||||
|
@ -54,18 +56,73 @@ export function FollowButton(props: {
|
||||||
|
|
||||||
export function UserFollowButton(props: { userId: string; small?: boolean }) {
|
export function UserFollowButton(props: { userId: string; small?: boolean }) {
|
||||||
const { userId, small } = props
|
const { userId, small } = props
|
||||||
const currentUser = useUser()
|
const user = useUser()
|
||||||
const following = useFollows(currentUser?.id)
|
const following = useFollows(user?.id)
|
||||||
const isFollowing = following?.includes(userId)
|
const isFollowing = following?.includes(userId)
|
||||||
|
|
||||||
if (!currentUser || currentUser.id === userId) return null
|
if (!user || user.id === userId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FollowButton
|
<FollowButton
|
||||||
isFollowing={isFollowing}
|
isFollowing={isFollowing}
|
||||||
onFollow={() => follow(currentUser.id, userId)}
|
onFollow={() => follow(user.id, userId)}
|
||||||
onUnfollow={() => unfollow(currentUser.id, userId)}
|
onUnfollow={() => unfollow(user.id, userId)}
|
||||||
small={small}
|
small={small}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MiniUserFollowButton(props: { userId: string }) {
|
||||||
|
const { userId } = props
|
||||||
|
const user = useUser()
|
||||||
|
const following = useFollows(user?.id)
|
||||||
|
const isFollowing = following?.includes(userId)
|
||||||
|
const isFirstRender = useRef(true)
|
||||||
|
const [justFollowed, setJustFollowed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
if (isFollowing != undefined) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isFollowing) {
|
||||||
|
setJustFollowed(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setJustFollowed(false)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}, [isFollowing])
|
||||||
|
|
||||||
|
if (justFollowed) {
|
||||||
|
return (
|
||||||
|
<CheckCircleIcon
|
||||||
|
className={clsx(
|
||||||
|
'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
user.id === userId ||
|
||||||
|
isFollowing ||
|
||||||
|
!user ||
|
||||||
|
isFollowing === undefined
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={withTracking(() => follow(user.id, userId), 'follow')}>
|
||||||
|
<PlusCircleIcon
|
||||||
|
className={clsx(
|
||||||
|
'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const FollowMarketButton = (props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size={'lg'}
|
size={'sm'}
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!user) return firebaseLogin()
|
if (!user) return firebaseLogin()
|
||||||
|
@ -56,13 +56,19 @@ export const FollowMarketButton = (props: {
|
||||||
>
|
>
|
||||||
{followers?.includes(user?.id ?? 'nope') ? (
|
{followers?.includes(user?.id ?? 'nope') ? (
|
||||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||||
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
<EyeOffIcon
|
||||||
Unwatch
|
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Unwatch */}
|
||||||
</Col>
|
</Col>
|
||||||
) : (
|
) : (
|
||||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||||
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
<EyeIcon
|
||||||
Watch
|
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Watch */}
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<WatchMarketModal
|
<WatchMarketModal
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
import { InfoTooltip } from './info-tooltip'
|
||||||
|
import { BETTORS, PRESENT_BET } from 'common/user'
|
||||||
|
|
||||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 text-gray-500">
|
<div className="mb-4 text-gray-500">
|
||||||
Contribute your M$ to make this market more accurate.{' '}
|
Contribute your M$ to make this market more accurate.{' '}
|
||||||
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
|
<InfoTooltip
|
||||||
|
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
|
|
94
web/components/nav/group-nav-bar.tsx
Normal file
94
web/components/nav/group-nav-bar.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
|
||||||
|
import { Item } from './sidebar'
|
||||||
|
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import NotificationsIcon from '../notifications-icon'
|
||||||
|
import router from 'next/router'
|
||||||
|
import { userProfileItem } from './nav-bar'
|
||||||
|
|
||||||
|
const mobileGroupNavigation = [
|
||||||
|
{ name: 'About', key: 'about', icon: ClipboardIcon },
|
||||||
|
{ name: 'Markets', key: 'markets', icon: HomeIcon },
|
||||||
|
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mobileGeneralNavigation = [
|
||||||
|
{
|
||||||
|
name: 'Notifications',
|
||||||
|
key: 'notifications',
|
||||||
|
icon: NotificationsIcon,
|
||||||
|
href: '/notifications',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function GroupNavBar(props: {
|
||||||
|
currentPage: string
|
||||||
|
onClick: (key: string) => void
|
||||||
|
}) {
|
||||||
|
const { currentPage } = props
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
||||||
|
{mobileGroupNavigation.map((item) => (
|
||||||
|
<NavBarItem
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onClick={props.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mobileGeneralNavigation.map((item) => (
|
||||||
|
<NavBarItem
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(item.href)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<NavBarItem
|
||||||
|
key={'profile'}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${user.username}?tab=trades`)
|
||||||
|
}}
|
||||||
|
item={userProfileItem(user)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBarItem(props: {
|
||||||
|
item: Item
|
||||||
|
currentPage: string
|
||||||
|
onClick: (key: string) => void
|
||||||
|
}) {
|
||||||
|
const { item, currentPage } = props
|
||||||
|
const track = trackCallback(
|
||||||
|
`group navbar: ${item.trackingEventName ?? item.name}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => props.onClick(item.key ?? '#')}>
|
||||||
|
<a
|
||||||
|
className={clsx(
|
||||||
|
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||||
|
currentPage === item.key && 'bg-gray-200 text-indigo-700'
|
||||||
|
)}
|
||||||
|
onClick={track}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
90
web/components/nav/group-sidebar.tsx
Normal file
90
web/components/nav/group-sidebar.tsx
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { ManifoldLogo } from './manifold-logo'
|
||||||
|
import { ProfileSummary } from './profile-menu'
|
||||||
|
import React from 'react'
|
||||||
|
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||||
|
import { SignInButton } from '../sign-in-button'
|
||||||
|
import CornerDownRightIcon from 'web/lib/icons/corner-down-right-icon'
|
||||||
|
import NotificationsIcon from '../notifications-icon'
|
||||||
|
import { SidebarItem } from './sidebar'
|
||||||
|
import { buildArray } from 'common/util/array'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
|
||||||
|
const groupNavigation = [
|
||||||
|
{ name: 'Markets', key: 'markets', icon: HomeIcon },
|
||||||
|
{ name: 'About', key: 'about', icon: ClipboardIcon },
|
||||||
|
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const generalNavigation = (user?: User | null) =>
|
||||||
|
buildArray(
|
||||||
|
user && {
|
||||||
|
name: 'Notifications',
|
||||||
|
href: `/notifications`,
|
||||||
|
key: 'notifications',
|
||||||
|
icon: NotificationsIcon,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export function GroupSidebar(props: {
|
||||||
|
groupName: string
|
||||||
|
className?: string
|
||||||
|
onClick: (key: string) => void
|
||||||
|
joinOrAddQuestionsButton: React.ReactNode
|
||||||
|
currentKey: string
|
||||||
|
}) {
|
||||||
|
const { className, groupName, currentKey } = props
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Group Sidebar"
|
||||||
|
className={clsx('flex max-h-[100vh] flex-col', className)}
|
||||||
|
>
|
||||||
|
<ManifoldLogo className="pt-6" twoLine />
|
||||||
|
<Row className="pl-2">
|
||||||
|
<Col className="flex justify-center">
|
||||||
|
<CornerDownRightIcon className=" h-6 w-6 text-indigo-700" />
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className={' text-2xl text-indigo-700 sm:mb-1 sm:mt-3'}>
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
|
||||||
|
{user ? (
|
||||||
|
<ProfileSummary user={user} />
|
||||||
|
) : (
|
||||||
|
<SignInButton className="mb-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop navigation */}
|
||||||
|
{groupNavigation.map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
currentPage={currentKey}
|
||||||
|
onClick={props.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{generalNavigation(user).map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
currentPage={currentKey}
|
||||||
|
onClick={props.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{props.joinOrAddQuestionsButton}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
|
@ -17,6 +17,9 @@ import { useRouter } from 'next/router'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
function getNavigation() {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
|
@ -34,6 +37,21 @@ const signedOutNavigation = [
|
||||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const userProfileItem = (user: User) => ({
|
||||||
|
name: formatMoney(user.balance),
|
||||||
|
trackingEventName: 'profile',
|
||||||
|
href: `/${user.username}?tab=${PAST_BETS}`,
|
||||||
|
icon: () => (
|
||||||
|
<Avatar
|
||||||
|
className="mx-auto my-1"
|
||||||
|
size="xs"
|
||||||
|
username={user.username}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
noLink
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
||||||
export function BottomNavBar() {
|
export function BottomNavBar() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
@ -61,20 +79,7 @@ export function BottomNavBar() {
|
||||||
<NavBarItem
|
<NavBarItem
|
||||||
key={'profile'}
|
key={'profile'}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
item={{
|
item={userProfileItem(user)}
|
||||||
name: formatMoney(user.balance),
|
|
||||||
trackingEventName: 'profile',
|
|
||||||
href: `/${user.username}?tab=trades`,
|
|
||||||
icon: () => (
|
|
||||||
<Avatar
|
|
||||||
className="mx-auto my-1"
|
|
||||||
size="xs"
|
|
||||||
username={user.username}
|
|
||||||
avatarUrl={user.avatarUrl}
|
|
||||||
noLink
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
@ -98,7 +103,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={item.href}>
|
<Link href={item.href ?? '#'}>
|
||||||
<a
|
<a
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||||
|
|
|
@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
export function ProfileSummary(props: { user: User }) {
|
export function ProfileSummary(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.username}?tab=trades`}>
|
<Link href={`/${user.username}?tab=${PAST_BETS}`}>
|
||||||
<a
|
<a
|
||||||
onClick={trackCallback('sidebar: profile')}
|
onClick={trackCallback('sidebar: profile')}
|
||||||
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
|
|
@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||||
import { ManifoldLogo } from './manifold-logo'
|
import { ManifoldLogo } from './manifold-logo'
|
||||||
import { MenuButton } from './menu'
|
import { MenuButton, MenuItem } from './menu'
|
||||||
import { ProfileSummary } from './profile-menu'
|
import { ProfileSummary } from './profile-menu'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
@ -139,7 +139,7 @@ function getMoreMobileNav() {
|
||||||
}
|
}
|
||||||
if (IS_PRIVATE_MANIFOLD) return [signOut]
|
if (IS_PRIVATE_MANIFOLD) return [signOut]
|
||||||
|
|
||||||
return buildArray<Item>(
|
return buildArray<MenuItem>(
|
||||||
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
[
|
[
|
||||||
{ name: 'Groups', href: '/groups' },
|
{ name: 'Groups', href: '/groups' },
|
||||||
|
@ -156,18 +156,25 @@ function getMoreMobileNav() {
|
||||||
export type Item = {
|
export type Item = {
|
||||||
name: string
|
name: string
|
||||||
trackingEventName?: string
|
trackingEventName?: string
|
||||||
href: string
|
href?: string
|
||||||
|
key?: string
|
||||||
icon?: React.ComponentType<{ className?: string }>
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem(props: { item: Item; currentPage: string }) {
|
export function SidebarItem(props: {
|
||||||
const { item, currentPage } = props
|
item: Item
|
||||||
return (
|
currentPage: string
|
||||||
<Link href={item.href} key={item.name}>
|
onClick?: (key: string) => void
|
||||||
|
}) {
|
||||||
|
const { item, currentPage, onClick } = props
|
||||||
|
const isCurrentPage =
|
||||||
|
item.href != null ? item.href === currentPage : item.key === currentPage
|
||||||
|
|
||||||
|
const sidebarItem = (
|
||||||
<a
|
<a
|
||||||
onClick={trackCallback('sidebar: ' + item.name)}
|
onClick={trackCallback('sidebar: ' + item.name)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
item.href == currentPage
|
isCurrentPage
|
||||||
? 'bg-gray-200 text-gray-900'
|
? 'bg-gray-200 text-gray-900'
|
||||||
: 'text-gray-600 hover:bg-gray-100',
|
: 'text-gray-600 hover:bg-gray-100',
|
||||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
|
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
|
||||||
|
@ -177,7 +184,7 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<item.icon
|
<item.icon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
item.href == currentPage
|
isCurrentPage
|
||||||
? 'text-gray-500'
|
? 'text-gray-500'
|
||||||
: 'text-gray-400 group-hover:text-gray-500',
|
: 'text-gray-400 group-hover:text-gray-500',
|
||||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||||
|
@ -187,8 +194,21 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<Link href={item.href} key={item.name}>
|
||||||
|
{sidebarItem}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return onClick ? (
|
||||||
|
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
|
||||||
|
) : (
|
||||||
|
<> </>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarButton(props: {
|
function SidebarButton(props: {
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import React, { memo, ReactNode, useEffect, useState } from 'react'
|
import React, { memo, ReactNode, useEffect, useState } from 'react'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import { PrivateUser } from 'common/user'
|
||||||
notification_subscription_types,
|
|
||||||
notification_destination_types,
|
|
||||||
PrivateUser,
|
|
||||||
} from 'common/user'
|
|
||||||
import { updatePrivateUser } from 'web/lib/firebase/users'
|
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import {
|
import {
|
||||||
|
@ -30,6 +26,11 @@ import {
|
||||||
usePersistentState,
|
usePersistentState,
|
||||||
} from 'web/hooks/use-persistent-state'
|
} from 'web/hooks/use-persistent-state'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
|
import { NOTIFICATION_DESCRIPTIONS } from 'common/notification'
|
||||||
|
import {
|
||||||
|
notification_destination_types,
|
||||||
|
notification_preference,
|
||||||
|
} from 'common/user-notification-preferences'
|
||||||
|
|
||||||
export function NotificationSettings(props: {
|
export function NotificationSettings(props: {
|
||||||
navigateToSection: string | undefined
|
navigateToSection: string | undefined
|
||||||
|
@ -38,7 +39,7 @@ export function NotificationSettings(props: {
|
||||||
const { navigateToSection, privateUser } = props
|
const { navigateToSection, privateUser } = props
|
||||||
const [showWatchModal, setShowWatchModal] = useState(false)
|
const [showWatchModal, setShowWatchModal] = useState(false)
|
||||||
|
|
||||||
const emailsEnabled: Array<keyof notification_subscription_types> = [
|
const emailsEnabled: Array<notification_preference> = [
|
||||||
'all_comments_on_watched_markets',
|
'all_comments_on_watched_markets',
|
||||||
'all_replies_to_my_comments_on_watched_markets',
|
'all_replies_to_my_comments_on_watched_markets',
|
||||||
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
@ -62,7 +63,6 @@ export function NotificationSettings(props: {
|
||||||
'contract_from_followed_user',
|
'contract_from_followed_user',
|
||||||
'unique_bettors_on_your_contract',
|
'unique_bettors_on_your_contract',
|
||||||
// TODO: add these
|
// TODO: add these
|
||||||
// one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications
|
|
||||||
// 'profit_loss_updates', - changes in markets you have shares in
|
// '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
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ export function NotificationSettings(props: {
|
||||||
// 'probability_updates_on_watched_markets',
|
// 'probability_updates_on_watched_markets',
|
||||||
// 'limit_order_fills',
|
// 'limit_order_fills',
|
||||||
]
|
]
|
||||||
const browserDisabled: Array<keyof notification_subscription_types> = [
|
const browserDisabled: Array<notification_preference> = [
|
||||||
'trending_markets',
|
'trending_markets',
|
||||||
'profit_loss_updates',
|
'profit_loss_updates',
|
||||||
'onboarding_flow',
|
'onboarding_flow',
|
||||||
|
@ -83,91 +83,82 @@ export function NotificationSettings(props: {
|
||||||
|
|
||||||
type SectionData = {
|
type SectionData = {
|
||||||
label: string
|
label: string
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: Partial<notification_preference>[]
|
||||||
[key in keyof Partial<notification_subscription_types>]: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const comments: SectionData = {
|
const comments: SectionData = {
|
||||||
label: 'New Comments',
|
label: 'New Comments',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
all_comments_on_watched_markets: 'All new comments',
|
'all_comments_on_watched_markets',
|
||||||
all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
|
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||||
// TODO: combine these two
|
// TODO: combine these two
|
||||||
all_replies_to_my_comments_on_watched_markets:
|
'all_replies_to_my_comments_on_watched_markets',
|
||||||
'Only replies to your comments',
|
'all_replies_to_my_answers_on_watched_markets',
|
||||||
all_replies_to_my_answers_on_watched_markets:
|
],
|
||||||
'Only replies to your answers',
|
|
||||||
// comments_by_followed_users_on_watched_markets: 'By followed users',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const answers: SectionData = {
|
const answers: SectionData = {
|
||||||
label: 'New Answers',
|
label: 'New Answers',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
all_answers_on_watched_markets: 'All new answers',
|
'all_answers_on_watched_markets',
|
||||||
all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
|
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||||
// answers_by_followed_users_on_watched_markets: 'By followed users',
|
],
|
||||||
// answers_by_market_creator_on_watched_markets: 'By market creator',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
const updates: SectionData = {
|
const updates: SectionData = {
|
||||||
label: 'Updates & Resolutions',
|
label: 'Updates & Resolutions',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
market_updates_on_watched_markets: 'All creator updates',
|
'market_updates_on_watched_markets',
|
||||||
market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`,
|
'market_updates_on_watched_markets_with_shares_in',
|
||||||
resolutions_on_watched_markets: 'All market resolutions',
|
'resolutions_on_watched_markets',
|
||||||
resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`,
|
'resolutions_on_watched_markets_with_shares_in',
|
||||||
// probability_updates_on_watched_markets: 'Probability updates',
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
const yourMarkets: SectionData = {
|
const yourMarkets: SectionData = {
|
||||||
label: 'Markets You Created',
|
label: 'Markets You Created',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
your_contract_closed: 'Your market has closed (and needs resolution)',
|
'your_contract_closed',
|
||||||
all_comments_on_my_markets: 'Comments on your markets',
|
'all_comments_on_my_markets',
|
||||||
all_answers_on_my_markets: 'Answers on your markets',
|
'all_answers_on_my_markets',
|
||||||
subsidized_your_market: 'Your market was subsidized',
|
'subsidized_your_market',
|
||||||
tips_on_your_markets: 'Likes on your markets',
|
'tips_on_your_markets',
|
||||||
},
|
],
|
||||||
}
|
}
|
||||||
const bonuses: SectionData = {
|
const bonuses: SectionData = {
|
||||||
label: 'Bonuses',
|
label: 'Bonuses',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
betting_streaks: 'Prediction streak bonuses',
|
'betting_streaks',
|
||||||
referral_bonuses: 'Referral bonuses from referring users',
|
'referral_bonuses',
|
||||||
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
|
'unique_bettors_on_your_contract',
|
||||||
},
|
],
|
||||||
}
|
}
|
||||||
const otherBalances: SectionData = {
|
const otherBalances: SectionData = {
|
||||||
label: 'Other',
|
label: 'Other',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
loan_income: 'Automatic loans from your profitable bets',
|
'loan_income',
|
||||||
limit_order_fills: 'Limit order fills',
|
'limit_order_fills',
|
||||||
tips_on_your_comments: 'Tips on your comments',
|
'tips_on_your_comments',
|
||||||
},
|
],
|
||||||
}
|
}
|
||||||
const userInteractions: SectionData = {
|
const userInteractions: SectionData = {
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
tagged_user: 'A user tagged you',
|
'tagged_user',
|
||||||
on_new_follow: 'Someone followed you',
|
'on_new_follow',
|
||||||
contract_from_followed_user: 'New markets created by users you follow',
|
'contract_from_followed_user',
|
||||||
},
|
],
|
||||||
}
|
}
|
||||||
const generalOther: SectionData = {
|
const generalOther: SectionData = {
|
||||||
label: 'Other',
|
label: 'Other',
|
||||||
subscriptionTypeToDescription: {
|
subscriptionTypes: [
|
||||||
trending_markets: 'Weekly interesting markets',
|
'trending_markets',
|
||||||
thank_you_for_purchases: 'Thank you notes for your purchases',
|
'thank_you_for_purchases',
|
||||||
onboarding_flow: 'Explanatory emails to help you get started',
|
'onboarding_flow',
|
||||||
// profit_loss_updates: 'Weekly profit/loss updates',
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
function NotificationSettingLine(props: {
|
||||||
description: string
|
description: string
|
||||||
subscriptionTypeKey: keyof notification_subscription_types
|
subscriptionTypeKey: notification_preference
|
||||||
destinations: notification_destination_types[]
|
destinations: notification_destination_types[]
|
||||||
}) {
|
}) {
|
||||||
const { description, subscriptionTypeKey, destinations } = props
|
const { description, subscriptionTypeKey, destinations } = props
|
||||||
|
@ -237,9 +228,7 @@ export function NotificationSettings(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsersSavedPreference = (
|
const getUsersSavedPreference = (key: notification_preference) => {
|
||||||
key: keyof notification_subscription_types
|
|
||||||
) => {
|
|
||||||
return privateUser.notificationPreferences[key] ?? []
|
return privateUser.notificationPreferences[key] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,17 +237,15 @@ export function NotificationSettings(props: {
|
||||||
data: SectionData
|
data: SectionData
|
||||||
}) {
|
}) {
|
||||||
const { icon, data } = props
|
const { icon, data } = props
|
||||||
const { label, subscriptionTypeToDescription } = data
|
const { label, subscriptionTypes } = data
|
||||||
const expand =
|
const expand =
|
||||||
navigateToSection &&
|
navigateToSection &&
|
||||||
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
|
subscriptionTypes.includes(navigateToSection as notification_preference)
|
||||||
|
|
||||||
// Not sure how to prevent re-render (and collapse of an open section)
|
// Not sure how to prevent re-render (and collapse of an open section)
|
||||||
// due to a private user settings change. Just going to persist expanded state here
|
// due to a private user settings change. Just going to persist expanded state here
|
||||||
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
|
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
|
||||||
key:
|
key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'),
|
||||||
'NotificationsSettingsSection-' +
|
|
||||||
Object.keys(subscriptionTypeToDescription).join('-'),
|
|
||||||
store: storageStore(safeLocalStorage()),
|
store: storageStore(safeLocalStorage()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -287,13 +274,13 @@ export function NotificationSettings(props: {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
||||||
{Object.entries(subscriptionTypeToDescription).map(([key, value]) => (
|
{subscriptionTypes.map((subType) => (
|
||||||
<NotificationSettingLine
|
<NotificationSettingLine
|
||||||
subscriptionTypeKey={key as keyof notification_subscription_types}
|
subscriptionTypeKey={subType as notification_preference}
|
||||||
destinations={getUsersSavedPreference(
|
destinations={getUsersSavedPreference(
|
||||||
key as keyof notification_subscription_types
|
subType as notification_preference
|
||||||
)}
|
)}
|
||||||
description={value}
|
description={NOTIFICATION_DESCRIPTIONS[subType].simple}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -10,13 +10,16 @@ import { NumericContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||||
import { BucketInput } from './bucket-input'
|
import { BucketInput } from './bucket-input'
|
||||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||||
|
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
export function NumericResolutionPanel(props: {
|
export function NumericResolutionPanel(props: {
|
||||||
|
isAdmin: boolean
|
||||||
|
isCreator: boolean
|
||||||
creator: User
|
creator: User
|
||||||
contract: NumericContract | PseudoNumericContract
|
contract: NumericContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, className } = props
|
const { contract, className, isAdmin, isCreator } = props
|
||||||
const { min, max, outcomeType } = contract
|
const { min, max, outcomeType } = contract
|
||||||
|
|
||||||
const [outcomeMode, setOutcomeMode] = useState<
|
const [outcomeMode, setOutcomeMode] = useState<
|
||||||
|
@ -78,10 +81,20 @@ export function NumericResolutionPanel(props: {
|
||||||
: 'btn-disabled'
|
: 'btn-disabled'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
<Col
|
||||||
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
|
className={clsx(
|
||||||
|
'relative w-full rounded-md bg-white px-8 py-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAdmin && !isCreator && (
|
||||||
|
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
|
||||||
|
ADMIN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="whitespace-nowrap text-2xl">Resolve market</div>
|
||||||
|
|
||||||
<div className="mb-3 text-sm text-gray-500">Outcome</div>
|
<div className="my-3 text-sm text-gray-500">Outcome</div>
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
|
@ -99,9 +112,12 @@ export function NumericResolutionPanel(props: {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{outcome === 'CANCEL' ? (
|
{outcome === 'CANCEL' ? (
|
||||||
<>All trades will be returned with no fees.</>
|
<>
|
||||||
|
All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
|
||||||
|
withdrawn from your account
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Resolving this market will immediately pay out traders.</>
|
<>Resolving this market will immediately pay out {BETTORS}.</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
export function LoansModal(props: {
|
export function LoansModal(props: {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
@ -11,7 +12,7 @@ export function LoansModal(props: {
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||||
<span className={'text-8xl'}>🏦</span>
|
<span className={'text-8xl'}>🏦</span>
|
||||||
<span className="text-xl">Daily loans on your trades</span>
|
<span className="text-xl">Daily loans on your {PAST_BETS}</span>
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
|
|
|
@ -1,133 +0,0 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { MouseEventHandler, ReactNode, useState } from 'react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
import { LinkIcon } from '@heroicons/react/solid'
|
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
|
||||||
import { updatePrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
|
||||||
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
|
||||||
import { Button, ColorType } from './../button'
|
|
||||||
import { Row } from './../layout/row'
|
|
||||||
import { LoadingIndicator } from './../loading-indicator'
|
|
||||||
|
|
||||||
function BouncyButton(props: {
|
|
||||||
children: ReactNode
|
|
||||||
onClick?: MouseEventHandler<any>
|
|
||||||
color?: ColorType
|
|
||||||
}) {
|
|
||||||
const { children, onClick, color } = props
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
color={color}
|
|
||||||
size="lg"
|
|
||||||
onClick={onClick}
|
|
||||||
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TwitchPanel() {
|
|
||||||
const user = useUser()
|
|
||||||
const privateUser = usePrivateUser()
|
|
||||||
|
|
||||||
const twitchInfo = privateUser?.twitchInfo
|
|
||||||
const twitchName = privateUser?.twitchInfo?.twitchName
|
|
||||||
const twitchToken = privateUser?.twitchInfo?.controlToken
|
|
||||||
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
|
|
||||||
|
|
||||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
|
||||||
|
|
||||||
const copyOverlayLink = async () => {
|
|
||||||
copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`)
|
|
||||||
toast.success('Overlay link copied!', {
|
|
||||||
icon: linkIcon,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyDockLink = async () => {
|
|
||||||
copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`)
|
|
||||||
toast.success('Dock link copied!', {
|
|
||||||
icon: linkIcon,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateBotConnected = (connected: boolean) => async () => {
|
|
||||||
if (user && twitchInfo) {
|
|
||||||
twitchInfo.botEnabled = connected
|
|
||||||
await updatePrivateUser(user.id, { twitchInfo })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [twitchLoading, setTwitchLoading] = useState(false)
|
|
||||||
|
|
||||||
const createLink = async () => {
|
|
||||||
if (!user || !privateUser) return
|
|
||||||
setTwitchLoading(true)
|
|
||||||
|
|
||||||
const promise = linkTwitchAccountRedirect(user, privateUser)
|
|
||||||
track('link twitch from profile')
|
|
||||||
await promise
|
|
||||||
|
|
||||||
setTwitchLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="label">Twitch</label>
|
|
||||||
|
|
||||||
{!twitchName ? (
|
|
||||||
<Row>
|
|
||||||
<Button
|
|
||||||
color="indigo"
|
|
||||||
onClick={createLink}
|
|
||||||
disabled={twitchLoading}
|
|
||||||
>
|
|
||||||
Link your Twitch account
|
|
||||||
</Button>
|
|
||||||
{twitchLoading && <LoadingIndicator className="ml-4" />}
|
|
||||||
</Row>
|
|
||||||
) : (
|
|
||||||
<Row>
|
|
||||||
<span className="mr-4 text-gray-500">Linked Twitch account</span>{' '}
|
|
||||||
{twitchName}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{twitchToken && (
|
|
||||||
<div>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'flex grow gap-4',
|
|
||||||
twitchToken ? '' : 'tooltip tooltip-top'
|
|
||||||
)}
|
|
||||||
data-tip="You must link your Twitch account first"
|
|
||||||
>
|
|
||||||
<BouncyButton color="blue" onClick={copyOverlayLink}>
|
|
||||||
Copy overlay link
|
|
||||||
</BouncyButton>
|
|
||||||
<BouncyButton color="indigo" onClick={copyDockLink}>
|
|
||||||
Copy dock link
|
|
||||||
</BouncyButton>
|
|
||||||
{twitchBotConnected ? (
|
|
||||||
<BouncyButton color="red" onClick={updateBotConnected(false)}>
|
|
||||||
Remove bot from your channel
|
|
||||||
</BouncyButton>
|
|
||||||
) : (
|
|
||||||
<BouncyButton color="green" onClick={updateBotConnected(true)}>
|
|
||||||
Add bot to your channel
|
|
||||||
</BouncyButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -10,13 +10,16 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||||
import { ProbabilitySelector } from './probability-selector'
|
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'
|
||||||
|
|
||||||
export function ResolutionPanel(props: {
|
export function ResolutionPanel(props: {
|
||||||
|
isAdmin: boolean
|
||||||
|
isCreator: boolean
|
||||||
creator: User
|
creator: User
|
||||||
contract: BinaryContract
|
contract: BinaryContract
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, className } = props
|
const { contract, className, isAdmin, isCreator } = props
|
||||||
|
|
||||||
// const earnedFees =
|
// const earnedFees =
|
||||||
// contract.mechanism === 'dpm-2'
|
// contract.mechanism === 'dpm-2'
|
||||||
|
@ -66,7 +69,12 @@ export function ResolutionPanel(props: {
|
||||||
: 'btn-disabled'
|
: 'btn-disabled'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
<Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}>
|
||||||
|
{isAdmin && !isCreator && (
|
||||||
|
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
|
||||||
|
ADMIN
|
||||||
|
</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>
|
||||||
|
@ -83,23 +91,28 @@ export function ResolutionPanel(props: {
|
||||||
<div>
|
<div>
|
||||||
{outcome === 'YES' ? (
|
{outcome === 'YES' ? (
|
||||||
<>
|
<>
|
||||||
Winnings will be paid out to traders who bought YES.
|
Winnings will be paid out to {BETTORS} who bought YES.
|
||||||
{/* <br />
|
{/* <br />
|
||||||
<br />
|
<br />
|
||||||
You will earn {earnedFees}. */}
|
You will earn {earnedFees}. */}
|
||||||
</>
|
</>
|
||||||
) : outcome === 'NO' ? (
|
) : outcome === 'NO' ? (
|
||||||
<>
|
<>
|
||||||
Winnings will be paid out to traders who bought NO.
|
Winnings will be paid out to {BETTORS} who bought NO.
|
||||||
{/* <br />
|
{/* <br />
|
||||||
<br />
|
<br />
|
||||||
You will earn {earnedFees}. */}
|
You will earn {earnedFees}. */}
|
||||||
</>
|
</>
|
||||||
) : outcome === 'CANCEL' ? (
|
) : outcome === 'CANCEL' ? (
|
||||||
<>All trades will be returned with no fees.</>
|
<>
|
||||||
|
All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
|
||||||
|
withdrawn from your account
|
||||||
|
</>
|
||||||
) : outcome === 'MKT' ? (
|
) : outcome === 'MKT' ? (
|
||||||
<Col className="gap-6">
|
<Col className="gap-6">
|
||||||
<div>Traders will be paid out at the probability you specify:</div>
|
<div>
|
||||||
|
{PAST_BETS} will be paid out at the probability you specify:
|
||||||
|
</div>
|
||||||
<ProbabilitySelector
|
<ProbabilitySelector
|
||||||
probabilityInt={Math.round(prob)}
|
probabilityInt={Math.round(prob)}
|
||||||
setProbabilityInt={setProb}
|
setProbabilityInt={setProb}
|
||||||
|
@ -107,7 +120,7 @@ export function ResolutionPanel(props: {
|
||||||
{/* You will earn {earnedFees}. */}
|
{/* You will earn {earnedFees}. */}
|
||||||
</Col>
|
</Col>
|
||||||
) : (
|
) : (
|
||||||
<>Resolving this market will immediately pay out traders.</>
|
<>Resolving this market will immediately pay out {BETTORS}.</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -20,13 +20,18 @@ export function UserLink(props: {
|
||||||
username: string
|
username: string
|
||||||
className?: string
|
className?: string
|
||||||
short?: boolean
|
short?: boolean
|
||||||
|
noLink?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { name, username, className, short } = props
|
const { name, username, className, short, noLink } = props
|
||||||
const shortName = short ? shortenName(name) : name
|
const shortName = short ? shortenName(name) : name
|
||||||
return (
|
return (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={`/${username}`}
|
href={`/${username}`}
|
||||||
className={clsx('z-10 truncate', className)}
|
className={clsx(
|
||||||
|
'z-10 truncate',
|
||||||
|
className,
|
||||||
|
noLink ? 'pointer-events-none' : ''
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{shortName}
|
{shortName}
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
|
|
@ -35,6 +35,8 @@ import {
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
import { LoansModal } from './profile/loans-modal'
|
import { LoansModal } from './profile/loans-modal'
|
||||||
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
import { capitalize } from 'lodash'
|
||||||
|
|
||||||
export function UserPage(props: { user: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
|
@ -240,7 +242,8 @@ export function UserPage(props: { user: User }) {
|
||||||
<SiteLink href="/referrals">
|
<SiteLink href="/referrals">
|
||||||
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
||||||
</SiteLink>{' '}
|
</SiteLink>{' '}
|
||||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
You've gotten{' '}
|
||||||
|
<ReferralsButton user={user} currentUser={currentUser} />
|
||||||
</span>
|
</span>
|
||||||
<ShareIconButton
|
<ShareIconButton
|
||||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||||
|
@ -269,7 +272,7 @@ export function UserPage(props: { user: User }) {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Trades',
|
title: capitalize(PAST_BETS),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<BetsList user={user} />
|
<BetsList user={user} />
|
||||||
|
|
|
@ -7,10 +7,11 @@ import {
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
getUserBetContracts,
|
getUserBetContracts,
|
||||||
getUserBetContractsQuery,
|
getUserBetContractsQuery,
|
||||||
|
listAllContracts,
|
||||||
trendingContractsQuery,
|
trendingContractsQuery,
|
||||||
getContractsQuery,
|
getContractsQuery,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { useQueryClient } from 'react-query'
|
import { QueryClient, useQueryClient } from 'react-query'
|
||||||
import { MINUTE_MS } from 'common/util/time'
|
import { MINUTE_MS } from 'common/util/time'
|
||||||
import { query, limit } from 'firebase/firestore'
|
import { query, limit } from 'firebase/firestore'
|
||||||
import { Sort } from 'web/components/contract-search'
|
import { Sort } from 'web/components/contract-search'
|
||||||
|
@ -25,6 +26,12 @@ export const useContracts = () => {
|
||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const q = new QueryClient()
|
||||||
|
export const getCachedContracts = async () =>
|
||||||
|
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
export const useTrendingContracts = (maxContracts: number) => {
|
export const useTrendingContracts = (maxContracts: number) => {
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['trending-contracts', maxContracts],
|
['trending-contracts', maxContracts],
|
||||||
|
|
6
web/hooks/use-is-mobile.ts
Normal file
6
web/hooks/use-is-mobile.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
return (width ?? 0) < 600
|
||||||
|
}
|
|
@ -98,7 +98,7 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationGroup: NotificationGroup = {
|
const notificationGroup: NotificationGroup = {
|
||||||
notifications: notificationsForContractId,
|
notifications: notificationsForContractId,
|
||||||
groupedById: contractId,
|
groupedById: contractId,
|
||||||
isSeen: notificationsForContractId[0].isSeen,
|
isSeen: notificationsForContractId.some((n) => !n.isSeen),
|
||||||
timePeriod: day,
|
timePeriod: day,
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
}
|
}
|
||||||
|
|
19
web/lib/icons/corner-down-right-icon.tsx
Normal file
19
web/lib/icons/corner-down-right-icon.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export default function CornerDownRightIcon(props: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
className={props.className}
|
||||||
|
>
|
||||||
|
<polyline points="15 10 20 15 15 20"></polyline>
|
||||||
|
<path d="M4 4v7a4 4 0 0 0 4 4h12"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key'
|
||||||
|
|
||||||
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
|
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
|
||||||
|
|
||||||
|
async function postToBot(url: string, body: unknown) {
|
||||||
|
const result = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const json = await result.json()
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(json.message)
|
||||||
|
} else {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initLinkTwitchAccount(
|
export async function initLinkTwitchAccount(
|
||||||
manifoldUserID: string,
|
manifoldUserID: string,
|
||||||
manifoldUserAPIKey: string
|
manifoldUserAPIKey: string
|
||||||
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
|
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
|
||||||
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
manifoldID: manifoldUserID,
|
manifoldID: manifoldUserID,
|
||||||
apiKey: manifoldUserAPIKey,
|
apiKey: manifoldUserAPIKey,
|
||||||
redirectURL: window.location.href,
|
redirectURL: window.location.href,
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const responseData = await response.json()
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(responseData.message)
|
|
||||||
}
|
|
||||||
const responseFetch = fetch(
|
const responseFetch = fetch(
|
||||||
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
|
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
|
||||||
)
|
)
|
||||||
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
|
return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function linkTwitchAccountRedirect(
|
export async function linkTwitchAccountRedirect(
|
||||||
|
@ -38,4 +42,34 @@ export async function linkTwitchAccountRedirect(
|
||||||
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
|
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
|
||||||
|
|
||||||
window.location.href = twitchAuthURL
|
window.location.href = twitchAuthURL
|
||||||
|
await new Promise((r) => setTimeout(r, 1e10)) // Wait "forever" for the page to change location
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBotEnabledForUser(
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
botEnabled: boolean
|
||||||
|
) {
|
||||||
|
if (botEnabled) {
|
||||||
|
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
|
||||||
|
apiKey: privateUser.apiKey,
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.success) throw new Error(r.message)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
|
||||||
|
apiKey: privateUser.apiKey,
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.success) throw new Error(r.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOverlayURLForUser(privateUser: PrivateUser) {
|
||||||
|
const controlToken = privateUser?.twitchInfo?.controlToken
|
||||||
|
return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDockURLForUser(privateUser: PrivateUser) {
|
||||||
|
const controlToken = privateUser?.twitchInfo?.controlToken
|
||||||
|
return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@
|
||||||
"nanoid": "^3.3.4",
|
"nanoid": "^3.3.4",
|
||||||
"next": "12.2.5",
|
"next": "12.2.5",
|
||||||
"node-fetch": "3.2.4",
|
"node-fetch": "3.2.4",
|
||||||
|
"prosemirror-state": "1.4.1",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-beautiful-dnd": "13.1.1",
|
"react-beautiful-dnd": "13.1.1",
|
||||||
"react-confetti": "6.0.1",
|
"react-confetti": "6.0.1",
|
||||||
|
|
|
@ -37,7 +37,6 @@ import { User } from 'common/user'
|
||||||
import { ContractComment } from 'common/comment'
|
import { ContractComment } from 'common/comment'
|
||||||
import { getOpenGraphProps } from 'common/contract-details'
|
import { getOpenGraphProps } from 'common/contract-details'
|
||||||
import { ContractDescription } from 'web/components/contract/contract-description'
|
import { ContractDescription } from 'web/components/contract/contract-description'
|
||||||
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
|
|
||||||
import {
|
import {
|
||||||
ContractLeaderboard,
|
ContractLeaderboard,
|
||||||
ContractTopTrades,
|
ContractTopTrades,
|
||||||
|
@ -45,6 +44,8 @@ import {
|
||||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
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 dayjs from 'dayjs'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -110,19 +111,28 @@ export default function ContractPage(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requires an admin to resolve a week after market closes
|
||||||
|
export function needsAdminToResolve(contract: Contract) {
|
||||||
|
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
|
||||||
|
}
|
||||||
|
|
||||||
export function ContractPageSidebar(props: {
|
export function ContractPageSidebar(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
contract: Contract
|
contract: Contract
|
||||||
}) {
|
}) {
|
||||||
const { contract, user } = props
|
const { contract, user } = props
|
||||||
const { creatorId, isResolved, outcomeType } = contract
|
const { creatorId, isResolved, outcomeType } = contract
|
||||||
|
|
||||||
const isCreator = user?.id === creatorId
|
const isCreator = user?.id === creatorId
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isNumeric = outcomeType === 'NUMERIC'
|
const isNumeric = outcomeType === 'NUMERIC'
|
||||||
const allowTrade = tradingAllowed(contract)
|
const allowTrade = tradingAllowed(contract)
|
||||||
const allowResolve = !isResolved && isCreator && !!user
|
const isAdmin = useAdmin()
|
||||||
|
const allowResolve =
|
||||||
|
!isResolved &&
|
||||||
|
(isCreator || (needsAdminToResolve(contract) && isAdmin)) &&
|
||||||
|
!!user
|
||||||
|
|
||||||
const hasSidePanel =
|
const hasSidePanel =
|
||||||
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
|
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
|
||||||
|
|
||||||
|
@ -139,9 +149,19 @@ export function ContractPageSidebar(props: {
|
||||||
))}
|
))}
|
||||||
{allowResolve &&
|
{allowResolve &&
|
||||||
(isNumeric || isPseudoNumeric ? (
|
(isNumeric || isPseudoNumeric ? (
|
||||||
<NumericResolutionPanel creator={user} contract={contract} />
|
<NumericResolutionPanel
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
creator={user}
|
||||||
|
isCreator={isCreator}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResolutionPanel creator={user} contract={contract} />
|
<ResolutionPanel
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
creator={user}
|
||||||
|
isCreator={isCreator}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
) : null
|
) : null
|
||||||
|
@ -154,10 +174,8 @@ export function ContractPageContent(
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { backToHome, comments, user } = props
|
const { backToHome, comments, user } = props
|
||||||
|
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
usePrefetch(user?.id)
|
usePrefetch(user?.id)
|
||||||
|
|
||||||
useTracking(
|
useTracking(
|
||||||
'view market',
|
'view market',
|
||||||
{
|
{
|
||||||
|
@ -238,7 +256,6 @@ export function ContractPageContent(
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContractOverview contract={contract} bets={nonChallengeBets} />
|
<ContractOverview contract={contract} bets={nonChallengeBets} />
|
||||||
<ExtraContractActionsRow contract={contract} />
|
|
||||||
<ContractDescription className="mb-6 px-2" contract={contract} />
|
<ContractDescription className="mb-6 px-2" contract={contract} />
|
||||||
|
|
||||||
{outcomeType === 'NUMERIC' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
usePersistentState,
|
usePersistentState,
|
||||||
urlParamStore,
|
urlParamStore,
|
||||||
} from 'web/hooks/use-persistent-state'
|
} from 'web/hooks/use-persistent-state'
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
const MAX_CONTRACTS_RENDERED = 100
|
const MAX_CONTRACTS_RENDERED = 100
|
||||||
|
|
||||||
|
@ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: {
|
||||||
>
|
>
|
||||||
<option value="score">Trending</option>
|
<option value="score">Trending</option>
|
||||||
<option value="newest">Newest</option>
|
<option value="newest">Newest</option>
|
||||||
<option value="most-traded">Most traded</option>
|
<option value="most-traded">Most ${PAST_BETS}</option>
|
||||||
<option value="24-hour-vol">24h volume</option>
|
<option value="24-hour-vol">24h volume</option>
|
||||||
<option value="close-date">Closing soon</option>
|
<option value="close-date">Closing soon</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
NumericResolutionOrExpectation,
|
NumericResolutionOrExpectation,
|
||||||
PseudoNumericResolutionOrExpectation,
|
PseudoNumericResolutionOrExpectation,
|
||||||
} from 'web/components/contract/contract-card'
|
} from 'web/components/contract/contract-card'
|
||||||
import { ContractDetails } from 'web/components/contract/contract-details'
|
import { MarketSubheader } from 'web/components/contract/contract-details'
|
||||||
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
|
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
|
||||||
import { NumericGraph } from 'web/components/contract/numeric-graph'
|
import { NumericGraph } from 'web/components/contract/numeric-graph'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
@ -102,30 +102,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="h-[100vh] w-full bg-white">
|
<Col className="h-[100vh] w-full bg-white">
|
||||||
<div className="relative flex flex-col pt-2">
|
<Row className="justify-between gap-4 px-2">
|
||||||
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
<div className="text-xl text-indigo-700 md:text-2xl">
|
||||||
<SiteLink href={href}>{question}</SiteLink>
|
<SiteLink href={href}>{question}</SiteLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spacer h={3} />
|
|
||||||
|
|
||||||
<Row className="items-center justify-between gap-4 px-2">
|
|
||||||
<ContractDetails contract={contract} disabled />
|
|
||||||
|
|
||||||
{(isBinary || isPseudoNumeric) &&
|
|
||||||
tradingAllowed(contract) &&
|
|
||||||
!betPanelOpen && (
|
|
||||||
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
|
||||||
Bet
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBinary && (
|
{isBinary && (
|
||||||
<BinaryResolutionOrChance
|
<BinaryResolutionOrChance contract={contract} probAfter={probAfter} />
|
||||||
contract={contract}
|
|
||||||
probAfter={probAfter}
|
|
||||||
className="items-center"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPseudoNumeric && (
|
{isPseudoNumeric && (
|
||||||
|
@ -133,19 +115,27 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{outcomeType === 'FREE_RESPONSE' && (
|
||||||
<FreeResponseResolutionOrChance
|
<FreeResponseResolutionOrChance contract={contract} truncate="long" />
|
||||||
contract={contract}
|
|
||||||
truncate="long"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'NUMERIC' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<NumericResolutionOrExpectation contract={contract} />
|
<NumericResolutionOrExpectation contract={contract} />
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
<Spacer h={3} />
|
||||||
|
<Row className="items-center justify-between gap-4 px-2">
|
||||||
|
<MarketSubheader contract={contract} disabled />
|
||||||
|
|
||||||
|
{(isBinary || isPseudoNumeric) &&
|
||||||
|
tradingAllowed(contract) &&
|
||||||
|
!betPanelOpen && (
|
||||||
|
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
||||||
|
Predict
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Spacer h={2} />
|
<Spacer h={2} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{(isBinary || isPseudoNumeric) && betPanelOpen && (
|
{(isBinary || isPseudoNumeric) && betPanelOpen && (
|
||||||
<BetInline
|
<BetInline
|
||||||
|
|
|
@ -148,6 +148,7 @@ function SearchSection(props: {
|
||||||
defaultPill={pill}
|
defaultPill={pill}
|
||||||
noControls
|
noControls
|
||||||
maxResults={6}
|
maxResults={6}
|
||||||
|
headerClassName="sticky"
|
||||||
persistPrefix={`experimental-home-${sort}`}
|
persistPrefix={`experimental-home-${sort}`}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast, Toaster } from 'react-hot-toast'
|
||||||
|
|
||||||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||||
import { Page } from 'web/components/page'
|
|
||||||
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
|
@ -30,7 +29,7 @@ import Custom404 from '../../404'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
import { ContractSearch } from 'web/components/contract-search'
|
import { ContractSearch } from 'web/components/contract-search'
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
|
@ -49,7 +48,11 @@ import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { usePost } from 'web/hooks/use-post'
|
import { usePost } from 'web/hooks/use-post'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/react/solid'
|
||||||
|
import { GroupSidebar } from 'web/components/nav/group-sidebar'
|
||||||
import { SelectMarketsModal } from 'web/components/contract-select-modal'
|
import { SelectMarketsModal } from 'web/components/contract-select-modal'
|
||||||
|
import { BETTORS } from 'common/user'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
|
@ -137,6 +140,7 @@ export default function GroupPage(props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||||
|
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrerUsername: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
|
@ -150,12 +154,12 @@ export default function GroupPage(props: {
|
||||||
const isMember = user && memberIds.includes(user.id)
|
const isMember = user && memberIds.includes(user.id)
|
||||||
const maxLeaderboardSize = 50
|
const maxLeaderboardSize = 50
|
||||||
|
|
||||||
const leaderboard = (
|
const leaderboardPage = (
|
||||||
<Col>
|
<Col>
|
||||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||||
<GroupLeaderboard
|
<GroupLeaderboard
|
||||||
topUsers={topTraders}
|
topUsers={topTraders}
|
||||||
title="🏅 Top traders"
|
title={`🏅 Top ${BETTORS}`}
|
||||||
header="Profit"
|
header="Profit"
|
||||||
maxToShow={maxLeaderboardSize}
|
maxToShow={maxLeaderboardSize}
|
||||||
/>
|
/>
|
||||||
|
@ -169,7 +173,7 @@ export default function GroupPage(props: {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
||||||
const aboutTab = (
|
const aboutPage = (
|
||||||
<Col>
|
<Col>
|
||||||
{(group.aboutPostId != null || isCreator || isAdmin) && (
|
{(group.aboutPostId != null || isCreator || isAdmin) && (
|
||||||
<GroupAboutPost
|
<GroupAboutPost
|
||||||
|
@ -189,73 +193,118 @@ export default function GroupPage(props: {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
||||||
const questionsTab = (
|
const questionsPage = (
|
||||||
<ContractSearch
|
<>
|
||||||
user={user}
|
{/* align the divs to the right */}
|
||||||
defaultSort={'newest'}
|
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
|
||||||
defaultFilter={suggestedFilter}
|
<div>
|
||||||
additionalFilter={{ groupSlug: group.slug }}
|
|
||||||
persistPrefix={`group-${group.slug}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
title: 'Markets',
|
|
||||||
content: questionsTab,
|
|
||||||
href: groupPath(group.slug, 'markets'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Leaderboards',
|
|
||||||
content: leaderboard,
|
|
||||||
href: groupPath(group.slug, 'leaderboards'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'About',
|
|
||||||
content: aboutTab,
|
|
||||||
href: groupPath(group.slug, 'about'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const tabIndex = tabs
|
|
||||||
.map((t) => t.title.toLowerCase())
|
|
||||||
.indexOf(page ?? 'markets')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<SEO
|
|
||||||
title={group.name}
|
|
||||||
description={`Created by ${creator.name}. ${group.about}`}
|
|
||||||
url={groupPath(group.slug)}
|
|
||||||
/>
|
|
||||||
<Col className="relative px-3">
|
|
||||||
<Row className={'items-center justify-between gap-4'}>
|
|
||||||
<div className={'sm:mb-1'}>
|
|
||||||
<div
|
|
||||||
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</div>
|
|
||||||
<div className={'hidden sm:block'}>
|
|
||||||
<Linkify text={group.about} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<JoinOrAddQuestionsButtons
|
<JoinOrAddQuestionsButtons
|
||||||
group={group}
|
group={group}
|
||||||
user={user}
|
user={user}
|
||||||
isMember={!!isMember}
|
isMember={!!isMember}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</div>
|
||||||
</Col>
|
<ContractSearch
|
||||||
<Tabs
|
headerClassName="md:sticky"
|
||||||
currentPageForAnalytics={groupPath(group.slug)}
|
user={user}
|
||||||
className={'mx-2 mb-0 sm:mb-2'}
|
defaultSort={'newest'}
|
||||||
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
defaultFilter={suggestedFilter}
|
||||||
tabs={tabs}
|
additionalFilter={{ groupSlug: group.slug }}
|
||||||
|
persistPrefix={`group-${group.slug}`}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const sidebarPages = [
|
||||||
|
{
|
||||||
|
title: 'Markets',
|
||||||
|
content: questionsPage,
|
||||||
|
href: groupPath(group.slug, 'markets'),
|
||||||
|
key: 'markets',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Leaderboards',
|
||||||
|
content: leaderboardPage,
|
||||||
|
href: groupPath(group.slug, 'leaderboards'),
|
||||||
|
key: 'leaderboards',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'About',
|
||||||
|
content: aboutPage,
|
||||||
|
href: groupPath(group.slug, 'about'),
|
||||||
|
key: 'about',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const pageContent = sidebarPages[sidebarIndex].content
|
||||||
|
const onSidebarClick = (key: string) => {
|
||||||
|
const index = sidebarPages.findIndex((t) => t.key === key)
|
||||||
|
setSidebarIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinOrAddQuestionsButton = (
|
||||||
|
<JoinOrAddQuestionsButtons
|
||||||
|
group={group}
|
||||||
|
user={user}
|
||||||
|
isMember={!!isMember}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TopGroupNavBar group={group} />
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Toaster />
|
||||||
|
<GroupSidebar
|
||||||
|
groupName={group.name}
|
||||||
|
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
|
||||||
|
onClick={onSidebarClick}
|
||||||
|
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
|
||||||
|
currentKey={sidebarPages[sidebarIndex].key}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SEO
|
||||||
|
title={group.name}
|
||||||
|
description={`Created by ${creator.name}. ${group.about}`}
|
||||||
|
url={groupPath(group.slug)}
|
||||||
|
/>
|
||||||
|
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
|
||||||
|
{pageContent}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<GroupNavBar
|
||||||
|
currentPage={sidebarPages[sidebarIndex].key}
|
||||||
|
onClick={onSidebarClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopGroupNavBar(props: { group: Group }) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12">
|
||||||
|
<div className="flex items-center border-b border-gray-200 bg-white px-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Link href="/">
|
||||||
|
<a className="text-indigo-700 hover:text-gray-500 ">
|
||||||
|
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h1 className="text-lg font-medium text-indigo-700">
|
||||||
|
{props.group.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -263,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: {
|
||||||
group: Group
|
group: Group
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
isMember: boolean
|
isMember: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { group, user, isMember } = props
|
const { group, user, isMember } = props
|
||||||
return user && isMember ? (
|
return user && isMember ? (
|
||||||
<Row className={'mt-0 justify-end'}>
|
<Row className={'w-full self-start pt-4'}>
|
||||||
<AddContractButton group={group} user={user} />
|
<AddContractButton group={group} user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
) : group.anyoneCanJoin ? (
|
) : group.anyoneCanJoin ? (
|
||||||
|
@ -410,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex w-full justify-center'}>
|
||||||
<Button
|
<Button
|
||||||
className="whitespace-nowrap"
|
className="w-full whitespace-nowrap"
|
||||||
size="md"
|
size="md"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
@ -467,7 +517,9 @@ function JoinGroupButton(props: {
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={follow}
|
onClick={follow}
|
||||||
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
|
className={
|
||||||
|
'btn-md btn-outline btn w-full whitespace-nowrap normal-case'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Follow
|
Follow
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -26,6 +26,7 @@ const Home = () => {
|
||||||
user={user}
|
user={user}
|
||||||
persistPrefix="home-search"
|
persistPrefix="home-search"
|
||||||
useQueryUrlParam={true}
|
useQueryUrlParam={true}
|
||||||
|
headerClassName="sticky"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { Title } from 'web/components/title'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
import { BETTORS } from 'common/user'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const props = await fetchProps()
|
const props = await fetchProps()
|
||||||
|
@ -79,7 +80,7 @@ export default function Leaderboards(_props: {
|
||||||
<>
|
<>
|
||||||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
title="🏅 Top traders"
|
title={`🏅 Top ${BETTORS}`}
|
||||||
users={topTraders}
|
users={topTraders}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
@ -126,7 +127,7 @@ export default function Leaderboards(_props: {
|
||||||
<Page>
|
<Page>
|
||||||
<SEO
|
<SEO
|
||||||
title="Leaderboards"
|
title="Leaderboards"
|
||||||
description="Manifold's leaderboards show the top traders and market creators."
|
description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`}
|
||||||
url="/leaderboards"
|
url="/leaderboards"
|
||||||
/>
|
/>
|
||||||
<Title text={'Leaderboards'} className={'hidden md:block'} />
|
<Title text={'Leaderboards'} className={'hidden md:block'} />
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import { ControlledTabs } from 'web/components/layout/tabs'
|
import { ControlledTabs } from 'web/components/layout/tabs'
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import Router, { useRouter } from 'next/router'
|
import Router, { useRouter } from 'next/router'
|
||||||
import { Notification, notification_source_types } from 'common/notification'
|
import {
|
||||||
|
BetFillData,
|
||||||
|
ContractResolutionData,
|
||||||
|
Notification,
|
||||||
|
notification_source_types,
|
||||||
|
} from 'common/notification'
|
||||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
|
@ -141,6 +146,7 @@ function RenderNotificationGroups(props: {
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
notification={notification.notifications[0]}
|
notification={notification.notifications[0]}
|
||||||
key={notification.notifications[0].id}
|
key={notification.notifications[0].id}
|
||||||
|
justSummary={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NotificationGroupItem
|
<NotificationGroupItem
|
||||||
|
@ -697,20 +703,11 @@ function NotificationGroupItem(props: {
|
||||||
|
|
||||||
function NotificationItem(props: {
|
function NotificationItem(props: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
justSummary?: boolean
|
justSummary: boolean
|
||||||
isChildOfGroup?: boolean
|
isChildOfGroup?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { notification, justSummary, isChildOfGroup } = props
|
const { notification, justSummary, isChildOfGroup } = props
|
||||||
const {
|
const { sourceType, reason, sourceUpdateType } = notification
|
||||||
sourceType,
|
|
||||||
sourceUserName,
|
|
||||||
sourceUserAvatarUrl,
|
|
||||||
sourceUpdateType,
|
|
||||||
reasonText,
|
|
||||||
reason,
|
|
||||||
sourceUserUsername,
|
|
||||||
sourceText,
|
|
||||||
} = notification
|
|
||||||
|
|
||||||
const [highlighted] = useState(!notification.isSeen)
|
const [highlighted] = useState(!notification.isSeen)
|
||||||
|
|
||||||
|
@ -718,9 +715,71 @@ function NotificationItem(props: {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
|
||||||
const questionNeedsResolution = sourceUpdateType == 'closed'
|
// TODO Any new notification should be its own component
|
||||||
|
if (reason === 'bet_fill') {
|
||||||
|
return (
|
||||||
|
<BetFillNotification
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
justSummary={justSummary}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
|
||||||
|
return (
|
||||||
|
<ContractResolvedNotification
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
justSummary={justSummary}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// TODO Add new notification components here
|
||||||
|
|
||||||
if (justSummary) {
|
if (justSummary) {
|
||||||
|
return (
|
||||||
|
<NotificationSummaryFrame
|
||||||
|
notification={notification}
|
||||||
|
subtitle={
|
||||||
|
(sourceType &&
|
||||||
|
reason &&
|
||||||
|
getReasonForShowingNotification(notification, true)) ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NotificationTextLabel
|
||||||
|
className={'line-clamp-1'}
|
||||||
|
notification={notification}
|
||||||
|
justSummary={true}
|
||||||
|
/>
|
||||||
|
</NotificationSummaryFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationFrame
|
||||||
|
notification={notification}
|
||||||
|
subtitle={getReasonForShowingNotification(
|
||||||
|
notification,
|
||||||
|
isChildOfGroup ?? false
|
||||||
|
)}
|
||||||
|
highlighted={highlighted}
|
||||||
|
>
|
||||||
|
<div className={'mt-1 ml-1 md:text-base'}>
|
||||||
|
<NotificationTextLabel notification={notification} />
|
||||||
|
</div>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationSummaryFrame(props: {
|
||||||
|
notification: Notification
|
||||||
|
subtitle: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { notification, subtitle, children } = props
|
||||||
|
const { sourceUserName, sourceUserUsername } = notification
|
||||||
return (
|
return (
|
||||||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
||||||
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
|
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
|
||||||
|
@ -732,25 +791,37 @@ function NotificationItem(props: {
|
||||||
short={true}
|
short={true}
|
||||||
/>
|
/>
|
||||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||||
<span className={'flex-shrink-0'}>
|
<span className={'flex-shrink-0'}>{subtitle}</span>
|
||||||
{sourceType &&
|
<div className={'line-clamp-1 ml-1 text-black'}>{children}</div>
|
||||||
reason &&
|
|
||||||
getReasonForShowingNotification(notification, true)}
|
|
||||||
</span>
|
|
||||||
<div className={'ml-1 text-black'}>
|
|
||||||
<NotificationTextLabel
|
|
||||||
className={'line-clamp-1'}
|
|
||||||
notification={notification}
|
|
||||||
justSummary={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationFrame(props: {
|
||||||
|
notification: Notification
|
||||||
|
highlighted: boolean
|
||||||
|
subtitle: string
|
||||||
|
children: React.ReactNode
|
||||||
|
isChildOfGroup?: boolean
|
||||||
|
}) {
|
||||||
|
const { notification, isChildOfGroup, highlighted, subtitle, children } =
|
||||||
|
props
|
||||||
|
const {
|
||||||
|
sourceType,
|
||||||
|
sourceUserName,
|
||||||
|
sourceUserAvatarUrl,
|
||||||
|
sourceUpdateType,
|
||||||
|
reason,
|
||||||
|
reasonText,
|
||||||
|
sourceUserUsername,
|
||||||
|
sourceText,
|
||||||
|
} = notification
|
||||||
|
const questionNeedsResolution = sourceUpdateType == 'closed'
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const isMobile = (width ?? 0) < 600
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -796,18 +867,13 @@ function NotificationItem(props: {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{!questionNeedsResolution && (
|
|
||||||
<UserLink
|
<UserLink
|
||||||
name={sourceUserName || ''}
|
name={sourceUserName || ''}
|
||||||
username={sourceUserUsername || ''}
|
username={sourceUserUsername || ''}
|
||||||
className={'relative mr-1 flex-shrink-0'}
|
className={'relative mr-1 flex-shrink-0'}
|
||||||
short={true}
|
short={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
{subtitle}
|
||||||
{getReasonForShowingNotification(
|
|
||||||
notification,
|
|
||||||
isChildOfGroup ?? false
|
|
||||||
)}
|
|
||||||
{isChildOfGroup ? (
|
{isChildOfGroup ? (
|
||||||
<RelativeTimestamp time={notification.createdTime} />
|
<RelativeTimestamp time={notification.createdTime} />
|
||||||
) : (
|
) : (
|
||||||
|
@ -822,9 +888,7 @@ function NotificationItem(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
<div className={'mt-1 ml-1 md:text-base'}>
|
<div className={'mt-1 ml-1 md:text-base'}>{children}</div>
|
||||||
<NotificationTextLabel notification={notification} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={'mt-6 border-b border-gray-300'} />
|
<div className={'mt-6 border-b border-gray-300'} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -832,6 +896,148 @@ function NotificationItem(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BetFillNotification(props: {
|
||||||
|
notification: Notification
|
||||||
|
highlighted: boolean
|
||||||
|
justSummary: boolean
|
||||||
|
isChildOfGroup?: boolean
|
||||||
|
}) {
|
||||||
|
const { notification, isChildOfGroup, highlighted, justSummary } = props
|
||||||
|
const { sourceText, data } = notification
|
||||||
|
const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
|
||||||
|
(data as BetFillData) ?? {}
|
||||||
|
const subtitle = 'bet against you'
|
||||||
|
const amount = formatMoney(parseInt(sourceText ?? '0'))
|
||||||
|
const description =
|
||||||
|
creatorOutcome && probability ? (
|
||||||
|
<span>
|
||||||
|
of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'mx-1',
|
||||||
|
creatorOutcome === 'YES'
|
||||||
|
? 'text-primary'
|
||||||
|
: creatorOutcome === 'NO'
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-blue-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{creatorOutcome}
|
||||||
|
</span>
|
||||||
|
limit order at {Math.round(probability * 100)}% was filled{' '}
|
||||||
|
{limitOrderRemaining
|
||||||
|
? `(${formatMoney(limitOrderRemaining)} remaining)`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>of your limit order was filled</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (justSummary) {
|
||||||
|
return (
|
||||||
|
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
|
||||||
|
<Row className={'line-clamp-1'}>
|
||||||
|
<span className={'text-primary mr-1'}>{amount}</span>
|
||||||
|
<span>{description}</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationSummaryFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationFrame
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
subtitle={subtitle}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<span>
|
||||||
|
<span className="text-primary mr-1">{amount}</span>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContractResolvedNotification(props: {
|
||||||
|
notification: Notification
|
||||||
|
highlighted: boolean
|
||||||
|
justSummary: boolean
|
||||||
|
isChildOfGroup?: boolean
|
||||||
|
}) {
|
||||||
|
const { notification, isChildOfGroup, highlighted, justSummary } = props
|
||||||
|
const { sourceText, data } = notification
|
||||||
|
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
|
||||||
|
const subtitle = 'resolved the market'
|
||||||
|
const resolutionDescription = () => {
|
||||||
|
if (!sourceText) return <div />
|
||||||
|
if (sourceText === 'YES' || sourceText == 'NO') {
|
||||||
|
return <BinaryOutcomeLabel outcome={sourceText as any} />
|
||||||
|
}
|
||||||
|
if (sourceText.includes('%'))
|
||||||
|
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
|
||||||
|
if (sourceText === 'CANCEL') return <CancelLabel />
|
||||||
|
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
|
||||||
|
|
||||||
|
// Numeric market
|
||||||
|
if (parseFloat(sourceText))
|
||||||
|
return <NumericValueLabel value={parseFloat(sourceText)} />
|
||||||
|
|
||||||
|
// Free response market
|
||||||
|
return (
|
||||||
|
<div className={'line-clamp-1 text-blue-400'}>
|
||||||
|
<Linkify text={sourceText} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const description =
|
||||||
|
userInvestment && userPayout !== undefined ? (
|
||||||
|
<Row className={'gap-1 '}>
|
||||||
|
{resolutionDescription()}
|
||||||
|
Invested:
|
||||||
|
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
|
||||||
|
Payout:
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
userPayout > 0 ? 'text-primary' : 'text-red-500',
|
||||||
|
'truncate'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(userPayout)}
|
||||||
|
{` (${userPayout > 0 ? '+' : ''}${Math.round(
|
||||||
|
((userPayout - userInvestment) / userInvestment) * 100
|
||||||
|
)}%)`}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<span>{resolutionDescription()}</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (justSummary) {
|
||||||
|
return (
|
||||||
|
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
|
||||||
|
<Row className={'line-clamp-1'}>{description}</Row>
|
||||||
|
</NotificationSummaryFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationFrame
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
subtitle={subtitle}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<span>{description}</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const setNotificationsAsSeen = async (notifications: Notification[]) => {
|
export const setNotificationsAsSeen = async (notifications: Notification[]) => {
|
||||||
const unseenNotifications = notifications.filter((n) => !n.isSeen)
|
const unseenNotifications = notifications.filter((n) => !n.isSeen)
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
|
@ -951,30 +1157,7 @@ function NotificationTextLabel(props: {
|
||||||
if (sourceType === 'contract') {
|
if (sourceType === 'contract') {
|
||||||
if (justSummary || !sourceText) return <div />
|
if (justSummary || !sourceText) return <div />
|
||||||
// Resolved contracts
|
// Resolved contracts
|
||||||
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
|
|
||||||
{
|
|
||||||
if (sourceText === 'YES' || sourceText == 'NO') {
|
|
||||||
return <BinaryOutcomeLabel outcome={sourceText as any} />
|
|
||||||
}
|
|
||||||
if (sourceText.includes('%'))
|
|
||||||
return (
|
|
||||||
<ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
|
|
||||||
)
|
|
||||||
if (sourceText === 'CANCEL') return <CancelLabel />
|
|
||||||
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
|
|
||||||
|
|
||||||
// Numeric market
|
|
||||||
if (parseFloat(sourceText))
|
|
||||||
return <NumericValueLabel value={parseFloat(sourceText)} />
|
|
||||||
|
|
||||||
// Free response market
|
|
||||||
return (
|
|
||||||
<div className={className ? className : 'line-clamp-1 text-blue-400'}>
|
|
||||||
<Linkify text={sourceText} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Close date will be a number - it looks better without it
|
// Close date will be a number - it looks better without it
|
||||||
if (sourceUpdateType === 'closed') {
|
if (sourceUpdateType === 'closed') {
|
||||||
return <div />
|
return <div />
|
||||||
|
@ -1002,15 +1185,6 @@ function NotificationTextLabel(props: {
|
||||||
return (
|
return (
|
||||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||||
)
|
)
|
||||||
} else if (sourceType === 'bet' && sourceText) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="text-primary">
|
|
||||||
{formatMoney(parseInt(sourceText))}
|
|
||||||
</span>{' '}
|
|
||||||
<span>of your limit order was filled</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
} else if (sourceType === 'challenge' && sourceText) {
|
} else if (sourceType === 'challenge' && sourceText) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -1074,9 +1248,6 @@ function getReasonForShowingNotification(
|
||||||
else if (sourceSlug) reasonText = 'joined because you shared'
|
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||||
else reasonText = 'joined because of you'
|
else reasonText = 'joined because of you'
|
||||||
break
|
break
|
||||||
case 'bet':
|
|
||||||
reasonText = 'bet against you'
|
|
||||||
break
|
|
||||||
case 'challenge':
|
case 'challenge':
|
||||||
reasonText = 'accepted your challenge'
|
reasonText = 'accepted your challenge'
|
||||||
break
|
break
|
||||||
|
|
|
@ -1,24 +1,28 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { RefreshIcon } from '@heroicons/react/outline'
|
import { RefreshIcon } from '@heroicons/react/outline'
|
||||||
|
import { PrivateUser, User } from 'common/user'
|
||||||
import { AddFundsButton } from 'web/components/add-funds-button'
|
|
||||||
import { Page } from 'web/components/page'
|
|
||||||
import { SEO } from 'web/components/SEO'
|
|
||||||
import { Title } from 'web/components/title'
|
|
||||||
import { formatMoney } from 'common/util/format'
|
|
||||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||||
import { changeUserInfo } from 'web/lib/firebase/api'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import Link from 'next/link'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import Textarea from 'react-expanding-textarea'
|
||||||
|
import { AddFundsButton } from 'web/components/add-funds-button'
|
||||||
|
import { ConfirmationButton } from 'web/components/confirmation-button'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { User, PrivateUser } from 'common/user'
|
import { Page } from 'web/components/page'
|
||||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { defaultBannerUrl } from 'web/components/user-page'
|
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import { Title } from 'web/components/title'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { defaultBannerUrl } from 'web/components/user-page'
|
||||||
import { generateNewApiKey } from 'web/lib/api/api-key'
|
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||||
import { TwitchPanel } from 'web/components/profile/twitch-panel'
|
import { changeUserInfo } from 'web/lib/firebase/api'
|
||||||
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
|
import {
|
||||||
|
getUserAndPrivateUser,
|
||||||
|
updatePrivateUser,
|
||||||
|
updateUser,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||||
|
@ -93,10 +97,15 @@ export default function ProfilePage(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateApiKey = async (e: React.MouseEvent) => {
|
const updateApiKey = async (e?: React.MouseEvent) => {
|
||||||
const newApiKey = await generateNewApiKey(user.id)
|
const newApiKey = await generateNewApiKey(user.id)
|
||||||
setApiKey(newApiKey ?? '')
|
setApiKey(newApiKey ?? '')
|
||||||
e.preventDefault()
|
e?.preventDefault()
|
||||||
|
|
||||||
|
if (!privateUser.twitchInfo) return
|
||||||
|
await updatePrivateUser(privateUser.id, {
|
||||||
|
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileHandler = async (event: any) => {
|
const fileHandler = async (event: any) => {
|
||||||
|
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button
|
<ConfirmationButton
|
||||||
className="btn btn-primary btn-square p-2"
|
openModalBtn={{
|
||||||
onClick={updateApiKey}
|
className: 'btn btn-primary btn-square p-2',
|
||||||
|
label: '',
|
||||||
|
icon: <RefreshIcon />,
|
||||||
|
}}
|
||||||
|
submitBtn={{
|
||||||
|
label: 'Update key',
|
||||||
|
className: 'btn-primary',
|
||||||
|
}}
|
||||||
|
onSubmitWithSuccess={async () => {
|
||||||
|
updateApiKey()
|
||||||
|
return true
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<Col>
|
||||||
</button>
|
<Title text={'Are you sure?'} />
|
||||||
|
<div>
|
||||||
|
Updating your API key will break any existing applications
|
||||||
|
connected to your account, <b>including the Twitch bot</b>.
|
||||||
|
You will need to go to the{' '}
|
||||||
|
<Link href="/twitch">
|
||||||
|
<a className="underline focus:outline-none">
|
||||||
|
Twitch page
|
||||||
|
</a>
|
||||||
|
</Link>{' '}
|
||||||
|
to relink your account.
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</ConfirmationButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TwitchPanel />
|
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { getStats } from 'web/lib/firebase/stats'
|
import { getStats } from 'web/lib/firebase/stats'
|
||||||
import { Stats } from 'common/stats'
|
import { Stats } from 'common/stats'
|
||||||
|
import { PAST_BETS } from 'common/user'
|
||||||
|
import { capitalize } from 'lodash'
|
||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
||||||
|
@ -156,7 +158,7 @@ export function CustomAnalytics(props: {
|
||||||
defaultIndex={0}
|
defaultIndex={0}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Trades',
|
title: capitalize(PAST_BETS),
|
||||||
content: (
|
content: (
|
||||||
<DailyCountChart
|
<DailyCountChart
|
||||||
dailyCounts={dailyBetCounts}
|
dailyCounts={dailyBetCounts}
|
||||||
|
|
|
@ -76,6 +76,13 @@ const Salem = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tourneys: Tourney[] = [
|
const tourneys: Tourney[] = [
|
||||||
|
{
|
||||||
|
title: 'Clearer Thinking Regrant Project',
|
||||||
|
blurb: 'Which projects will Clearer Thinking give a grant to?',
|
||||||
|
award: '$13,000',
|
||||||
|
endTime: toDate('Sep 22, 2022'),
|
||||||
|
groupId: 'fhksfIgqyWf7OxsV9nkM',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Manifold F2P Tournament',
|
title: 'Manifold F2P Tournament',
|
||||||
blurb:
|
blurb:
|
||||||
|
@ -99,13 +106,6 @@ const tourneys: Tourney[] = [
|
||||||
endTime: toDate('Jan 6, 2023'),
|
endTime: toDate('Jan 6, 2023'),
|
||||||
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// title: 'Clearer Thinking Regrant Project',
|
|
||||||
// blurb: 'Something amazing',
|
|
||||||
// award: '$10,000',
|
|
||||||
// endTime: toDate('Sep 22, 2022'),
|
|
||||||
// groupId: '2VsVVFGhKtIdJnQRAXVb',
|
|
||||||
// },
|
|
||||||
|
|
||||||
// Tournaments without awards get featured belows
|
// Tournaments without awards get featured belows
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,28 +1,48 @@
|
||||||
import { useState } from 'react'
|
import { LinkIcon } from '@heroicons/react/solid'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { PrivateUser, User } from 'common/user'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { MouseEventHandler, ReactNode, useState } from 'react'
|
||||||
|
|
||||||
import { Page } from 'web/components/page'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
|
||||||
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
|
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
|
||||||
import { SEO } from 'web/components/SEO'
|
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
|
||||||
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
|
||||||
import { Row } from 'web/components/layout/row'
|
|
||||||
import { Button } from 'web/components/button'
|
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
|
||||||
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
|
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
|
||||||
|
import { Page } from 'web/components/page'
|
||||||
|
import { SEO } from 'web/components/SEO'
|
||||||
|
import { Title } from 'web/components/title'
|
||||||
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||||
|
import {
|
||||||
|
firebaseLogin,
|
||||||
|
getUserAndPrivateUser,
|
||||||
|
updatePrivateUser,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import {
|
||||||
|
getDockURLForUser,
|
||||||
|
getOverlayURLForUser,
|
||||||
|
linkTwitchAccountRedirect,
|
||||||
|
updateBotEnabledForUser,
|
||||||
|
} from 'web/lib/twitch/link-twitch-account'
|
||||||
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
|
|
||||||
export default function TwitchLandingPage() {
|
function ButtonGetStarted(props: {
|
||||||
useSaveReferral()
|
user?: User | null
|
||||||
useTracking('view twitch landing page')
|
privateUser?: PrivateUser | null
|
||||||
|
buttonClass?: string
|
||||||
|
spinnerClass?: string
|
||||||
|
}) {
|
||||||
|
const { user, privateUser, buttonClass, spinnerClass } = props
|
||||||
|
|
||||||
const user = useUser()
|
const [isLoading, setLoading] = useState(false)
|
||||||
const privateUser = usePrivateUser()
|
const needsRelink =
|
||||||
const twitchUser = privateUser?.twitchInfo?.twitchName
|
privateUser?.twitchInfo?.twitchName &&
|
||||||
|
privateUser?.twitchInfo?.needsRelinking
|
||||||
|
|
||||||
const callback =
|
const callback =
|
||||||
user && privateUser
|
user && privateUser
|
||||||
|
@ -34,11 +54,11 @@ export default function TwitchLandingPage() {
|
||||||
const { user, privateUser } = await getUserAndPrivateUser(userId)
|
const { user, privateUser } = await getUserAndPrivateUser(userId)
|
||||||
if (!user || !privateUser) return
|
if (!user || !privateUser) return
|
||||||
|
|
||||||
|
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
|
||||||
|
|
||||||
await linkTwitchAccountRedirect(user, privateUser)
|
await linkTwitchAccountRedirect(user, privateUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [isLoading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const getStarted = async () => {
|
const getStarted = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
@ -49,9 +69,335 @@ export default function TwitchLandingPage() {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error('Failed to sign up. Please try again later.')
|
toast.error('Failed to sign up. Please try again later.')
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return isLoading ? (
|
||||||
|
<LoadingIndicator
|
||||||
|
spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
color={needsRelink ? 'red' : 'gradient'}
|
||||||
|
className={clsx('my-4 self-center !px-16', buttonClass)}
|
||||||
|
onClick={getStarted}
|
||||||
|
>
|
||||||
|
{needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwitchPlaysManifoldMarkets(props: {
|
||||||
|
user?: User | null
|
||||||
|
privateUser?: PrivateUser | null
|
||||||
|
}) {
|
||||||
|
const { user, privateUser } = props
|
||||||
|
|
||||||
|
const twitchInfo = privateUser?.twitchInfo
|
||||||
|
const twitchUser = twitchInfo?.twitchName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row className="mb-4">
|
||||||
|
<img
|
||||||
|
src="/twitch-glitch.svg"
|
||||||
|
className="mb-[0.4rem] mr-4 inline h-10 w-10"
|
||||||
|
></img>
|
||||||
|
<Title
|
||||||
|
text={'Twitch plays Manifold Markets'}
|
||||||
|
className={'!-my-0 md:block'}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Col className="gap-4">
|
||||||
|
<div>
|
||||||
|
Similar to Twitch channel point predictions, Manifold Markets allows
|
||||||
|
you to create and feature on stream any question you like with users
|
||||||
|
predicting to earn play money.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
The key difference is that Manifold's questions function more like a
|
||||||
|
stock market and viewers can buy and sell shares over the course of
|
||||||
|
the event and not just at the start. The market will eventually
|
||||||
|
resolve to yes or no at which point the winning shareholders will
|
||||||
|
receive their profit.
|
||||||
|
</div>
|
||||||
|
Start playing now by logging in with Google and typing commands in chat!
|
||||||
|
{twitchUser && !twitchInfo.needsRelinking ? (
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
color="green"
|
||||||
|
className="btn-disabled my-4 self-center !border-none"
|
||||||
|
>
|
||||||
|
Account connected: {twitchUser}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ButtonGetStarted user={user} privateUser={privateUser} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
Instead of Twitch channel points we use our play money, Mana (M$). All
|
||||||
|
viewers start with M$1000 and more can be earned for free and then{' '}
|
||||||
|
<Link href="/charity">
|
||||||
|
<a className="underline">donated to a charity</a>
|
||||||
|
</Link>{' '}
|
||||||
|
of their choice at no cost!
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Subtitle(props: { text: string }) {
|
||||||
|
const { text } = props
|
||||||
|
return <div className="text-2xl">{text}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Command(props: { command: string; desc: string }) {
|
||||||
|
const { command, desc } = props
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="inline font-bold">{'!' + command}</p>
|
||||||
|
{' - '}
|
||||||
|
<p className="inline">{desc}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwitchChatCommands() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text="Twitch Chat Commands" className="md:block" />
|
||||||
|
<Col className="gap-4">
|
||||||
|
<Subtitle text="For Chat" />
|
||||||
|
<Command command="bet yes#" desc="Bets a # of Mana on yes." />
|
||||||
|
<Command command="bet no#" desc="Bets a # of Mana on no." />
|
||||||
|
<Command
|
||||||
|
command="sell"
|
||||||
|
desc="Sells all shares you own. Using this command causes you to
|
||||||
|
cash out early before the market resolves. This could be profitable
|
||||||
|
(if the probability has moved towards the direction you bet) or cause
|
||||||
|
a loss, although at least you keep some Mana. For maximum profit (but
|
||||||
|
also risk) it is better to not sell and wait for a favourable
|
||||||
|
resolution."
|
||||||
|
/>
|
||||||
|
<Command command="balance" desc="Shows how much Mana you own." />
|
||||||
|
<Command command="allin yes" desc="Bets your entire balance on yes." />
|
||||||
|
<Command command="allin no" desc="Bets your entire balance on no." />
|
||||||
|
|
||||||
|
<Subtitle text="For Mods/Streamer" />
|
||||||
|
<Command
|
||||||
|
command="create <question>"
|
||||||
|
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
|
||||||
|
/>
|
||||||
|
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
|
||||||
|
<Command command="resolve no" desc="Resolves the market as 'No'." />
|
||||||
|
<Command
|
||||||
|
command="resolve n/a"
|
||||||
|
desc="Resolves the market as 'N/A' and refunds everyone their Mana."
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BotSetupStep(props: {
|
||||||
|
stepNum: number
|
||||||
|
buttonName?: string
|
||||||
|
buttonOnClick?: MouseEventHandler
|
||||||
|
overrideButton?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
|
||||||
|
return (
|
||||||
|
<Col className="flex-1">
|
||||||
|
{(overrideButton || buttonName) && (
|
||||||
|
<>
|
||||||
|
{overrideButton ?? (
|
||||||
|
<Button
|
||||||
|
size={'md'}
|
||||||
|
color={'green'}
|
||||||
|
className="!border-none"
|
||||||
|
onClick={buttonOnClick}
|
||||||
|
>
|
||||||
|
{buttonName}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Spacer h={4} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="inline font-bold">Step {stepNum}. </p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BotConnectButton(props: {
|
||||||
|
privateUser: PrivateUser | null | undefined
|
||||||
|
}) {
|
||||||
|
const { privateUser } = props
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const updateBotConnected = (connected: boolean) => async () => {
|
||||||
|
if (!privateUser) return
|
||||||
|
const twitchInfo = privateUser.twitchInfo
|
||||||
|
if (!twitchInfo) return
|
||||||
|
|
||||||
|
const error = connected
|
||||||
|
? 'Failed to add bot to your channel'
|
||||||
|
: 'Failed to remove bot from your channel'
|
||||||
|
const success = connected
|
||||||
|
? 'Added bot to your channel'
|
||||||
|
: 'Removed bot from your channel'
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
toast.promise(
|
||||||
|
updateBotEnabledForUser(privateUser, connected)
|
||||||
|
.then(() =>
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
twitchInfo: { ...twitchInfo, botEnabled: connected },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false)),
|
||||||
|
{ loading: 'Updating bot settings...', error, success },
|
||||||
|
{
|
||||||
|
loading: {
|
||||||
|
className: '!max-w-sm',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
className:
|
||||||
|
'!bg-primary !transition-all !duration-500 !text-white !max-w-sm',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
className:
|
||||||
|
'!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{privateUser?.twitchInfo?.botEnabled ? (
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={updateBotConnected(false)}
|
||||||
|
className={clsx(loading && '!btn-disabled', 'border-none')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
|
||||||
|
) : (
|
||||||
|
'Remove bot from channel'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
onClick={updateBotConnected(true)}
|
||||||
|
className={clsx(loading && '!btn-disabled', 'border-none')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
|
||||||
|
) : (
|
||||||
|
'Add bot to your channel'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetUpBot(props: {
|
||||||
|
user?: User | null
|
||||||
|
privateUser?: PrivateUser | null
|
||||||
|
}) {
|
||||||
|
const { user, privateUser } = props
|
||||||
|
const twitchLinked =
|
||||||
|
privateUser?.twitchInfo?.twitchName &&
|
||||||
|
!privateUser?.twitchInfo?.needsRelinking
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
const toastTheme = {
|
||||||
|
className: '!bg-primary !text-white',
|
||||||
|
icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />,
|
||||||
|
}
|
||||||
|
const copyOverlayLink = async () => {
|
||||||
|
if (!privateUser) return
|
||||||
|
copyToClipboard(getOverlayURLForUser(privateUser))
|
||||||
|
toast.success('Overlay link copied!', toastTheme)
|
||||||
|
}
|
||||||
|
const copyDockLink = async () => {
|
||||||
|
if (!privateUser) return
|
||||||
|
copyToClipboard(getDockURLForUser(privateUser))
|
||||||
|
toast.success('Dock link copied!', toastTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title
|
||||||
|
text={'Set up the bot for your own stream'}
|
||||||
|
className={'!mb-4 md:block'}
|
||||||
|
/>
|
||||||
|
<Col className="gap-4">
|
||||||
|
<img
|
||||||
|
src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder
|
||||||
|
className="!-my-2"
|
||||||
|
></img>
|
||||||
|
To add the bot to your stream make sure you have logged in then follow
|
||||||
|
the steps below.
|
||||||
|
{!twitchLinked && (
|
||||||
|
<ButtonGetStarted
|
||||||
|
user={user}
|
||||||
|
privateUser={privateUser}
|
||||||
|
buttonClass={'!my-0'}
|
||||||
|
spinnerClass={'!my-0'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-6 sm:flex-row">
|
||||||
|
<BotSetupStep
|
||||||
|
stepNum={1}
|
||||||
|
overrideButton={
|
||||||
|
twitchLinked && <BotConnectButton privateUser={privateUser} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Use the button above to add the bot to your channel. Then mod it by
|
||||||
|
typing in your Twitch chat: <b>/mod ManifoldBot</b>
|
||||||
|
<br />
|
||||||
|
If the bot is not modded it will not be able to respond to commands
|
||||||
|
properly.
|
||||||
|
</BotSetupStep>
|
||||||
|
<BotSetupStep
|
||||||
|
stepNum={2}
|
||||||
|
buttonName={twitchLinked && 'Overlay link'}
|
||||||
|
buttonOnClick={copyOverlayLink}
|
||||||
|
>
|
||||||
|
Create a new browser source in your streaming software such as OBS.
|
||||||
|
Paste in the above link and resize it to your liking. We recommend
|
||||||
|
setting the size to 400x400.
|
||||||
|
</BotSetupStep>
|
||||||
|
<BotSetupStep
|
||||||
|
stepNum={3}
|
||||||
|
buttonName={twitchLinked && 'Control dock link'}
|
||||||
|
buttonOnClick={copyDockLink}
|
||||||
|
>
|
||||||
|
The bot can be controlled entirely through chat. But we made an easy
|
||||||
|
to use control panel. Share the link with your mods or embed it into
|
||||||
|
your OBS as a custom dock.
|
||||||
|
</BotSetupStep>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TwitchLandingPage() {
|
||||||
|
useSaveReferral()
|
||||||
|
useTracking('view twitch landing page')
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
|
const privateUser = usePrivateUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -62,58 +408,11 @@ export default function TwitchLandingPage() {
|
||||||
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
||||||
<ManifoldLogo />
|
<ManifoldLogo />
|
||||||
</div>
|
</div>
|
||||||
<Col className="items-center">
|
|
||||||
<Col className="max-w-3xl">
|
|
||||||
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
|
||||||
<Row className="self-center">
|
|
||||||
<img height={200} width={200} src="/twitch-logo.png" />
|
|
||||||
<img height={200} width={200} src="/flappy-logo.gif" />
|
|
||||||
</Row>
|
|
||||||
<div className="m-4 max-w-[550px] self-center">
|
|
||||||
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
|
|
||||||
<div className="font-semibold sm:mb-2">
|
|
||||||
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
|
|
||||||
Bet
|
|
||||||
</span>{' '}
|
|
||||||
on your favorite streams
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
<Spacer h={6} />
|
|
||||||
<div className="mb-4 px-2 ">
|
|
||||||
Get more out of Twitch with play-money betting markets.{' '}
|
|
||||||
{!twitchUser &&
|
|
||||||
'Click the button below to link your Twitch account.'}
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Spacer h={6} />
|
<Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10">
|
||||||
|
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
|
||||||
{twitchUser ? (
|
<TwitchChatCommands />
|
||||||
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
|
<SetUpBot user={user} privateUser={privateUser} />
|
||||||
<div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
|
|
||||||
<div className="truncate text-sm font-medium text-gray-500">
|
|
||||||
Twitch account linked
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
|
||||||
{twitchUser}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<LoadingIndicator spinnerClassName="!w-16 !h-16" />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="2xl"
|
|
||||||
color="gradient"
|
|
||||||
className="self-center"
|
|
||||||
onClick={getStarted}
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
21
web/public/twitch-glitch.svg
Normal file
21
web/public/twitch-glitch.svg
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
.st1{fill:#9146FF;}
|
||||||
|
</style>
|
||||||
|
<title>Asset 2</title>
|
||||||
|
<g>
|
||||||
|
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
|
||||||
|
<g>
|
||||||
|
<g id="Layer_1-2">
|
||||||
|
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
|
||||||
|
V1300z"/>
|
||||||
|
<rect x="1700" y="550" class="st1" width="200" height="600"/>
|
||||||
|
<rect x="1150" y="550" class="st1" width="200" height="600"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 890 B |
|
@ -26,6 +26,8 @@ module.exports = {
|
||||||
'greyscale-5': '#9191A7',
|
'greyscale-5': '#9191A7',
|
||||||
'greyscale-6': '#66667C',
|
'greyscale-6': '#66667C',
|
||||||
'greyscale-7': '#111140',
|
'greyscale-7': '#111140',
|
||||||
|
'highlight-blue': '#5BCEFF',
|
||||||
|
'hover-blue': '#90DEFF',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
quoteless: {
|
quoteless: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user