Merge branch 'main' into new-home-3
This commit is contained in:
commit
47f758bba8
|
@ -1,10 +1,9 @@
|
||||||
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
||||||
import { CPMMContract } from './contract'
|
import { CPMMContract } from './contract'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { User } from './user'
|
|
||||||
|
|
||||||
export const getNewLiquidityProvision = (
|
export const getNewLiquidityProvision = (
|
||||||
user: User,
|
userId: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: CPMMContract,
|
contract: CPMMContract,
|
||||||
newLiquidityProvisionId: string
|
newLiquidityProvisionId: string
|
||||||
|
@ -18,7 +17,7 @@ export const getNewLiquidityProvision = (
|
||||||
|
|
||||||
const newLiquidityProvision: LiquidityProvision = {
|
const newLiquidityProvision: LiquidityProvision = {
|
||||||
id: newLiquidityProvisionId,
|
id: newLiquidityProvisionId,
|
||||||
userId: user.id,
|
userId: userId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
pool: newPool,
|
pool: newPool,
|
||||||
|
|
|
@ -15,6 +15,12 @@ import { Answer } from './answer'
|
||||||
|
|
||||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||||
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
||||||
|
export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20
|
||||||
|
|
||||||
|
type NormalizedBet<T extends Bet = Bet> = Omit<
|
||||||
|
T,
|
||||||
|
'userAvatarUrl' | 'userName' | 'userUsername'
|
||||||
|
>
|
||||||
|
|
||||||
export function getCpmmInitialLiquidity(
|
export function getCpmmInitialLiquidity(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
|
@ -51,7 +57,7 @@ export function getAnteBets(
|
||||||
|
|
||||||
const { createdTime } = contract
|
const { createdTime } = contract
|
||||||
|
|
||||||
const yesBet: Bet = {
|
const yesBet: NormalizedBet = {
|
||||||
id: yesAnteId,
|
id: yesAnteId,
|
||||||
userId: creator.id,
|
userId: creator.id,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
|
@ -65,7 +71,7 @@ export function getAnteBets(
|
||||||
fees: noFees,
|
fees: noFees,
|
||||||
}
|
}
|
||||||
|
|
||||||
const noBet: Bet = {
|
const noBet: NormalizedBet = {
|
||||||
id: noAnteId,
|
id: noAnteId,
|
||||||
userId: creator.id,
|
userId: creator.id,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
|
@ -93,7 +99,7 @@ export function getFreeAnswerAnte(
|
||||||
|
|
||||||
const { createdTime } = contract
|
const { createdTime } = contract
|
||||||
|
|
||||||
const anteBet: Bet = {
|
const anteBet: NormalizedBet = {
|
||||||
id: anteBetId,
|
id: anteBetId,
|
||||||
userId: anteBettorId,
|
userId: anteBettorId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
|
@ -123,7 +129,7 @@ export function getMultipleChoiceAntes(
|
||||||
|
|
||||||
const { createdTime } = contract
|
const { createdTime } = contract
|
||||||
|
|
||||||
const bets: Bet[] = answers.map((answer, i) => ({
|
const bets: NormalizedBet[] = answers.map((answer, i) => ({
|
||||||
id: betDocIds[i],
|
id: betDocIds[i],
|
||||||
userId: creator.id,
|
userId: creator.id,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
|
@ -173,7 +179,7 @@ export function getNumericAnte(
|
||||||
range(0, bucketCount).map((_, i) => [i, betAnte])
|
range(0, bucketCount).map((_, i) => [i, betAnte])
|
||||||
)
|
)
|
||||||
|
|
||||||
const anteBet: NumericBet = {
|
const anteBet: NormalizedBet<NumericBet> = {
|
||||||
id: newBetId,
|
id: newBetId,
|
||||||
userId: anteBettorId,
|
userId: anteBettorId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
|
|
|
@ -3,6 +3,12 @@ import { Fees } from './fees'
|
||||||
export type Bet = {
|
export type Bet = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
|
// denormalized for bet lists
|
||||||
|
userAvatarUrl?: string
|
||||||
|
userUsername: string
|
||||||
|
userName: string
|
||||||
|
|
||||||
contractId: string
|
contractId: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
|
||||||
|
|
|
@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
||||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
|
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
|
||||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||||
|
|
|
@ -12,7 +12,18 @@ export type Group = {
|
||||||
aboutPostId?: string
|
aboutPostId?: string
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
mostRecentContractAddedTime?: number
|
mostRecentContractAddedTime?: number
|
||||||
|
cachedLeaderboard?: {
|
||||||
|
topTraders: {
|
||||||
|
userId: string
|
||||||
|
score: number
|
||||||
|
}[]
|
||||||
|
topCreators: {
|
||||||
|
userId: string
|
||||||
|
score: number
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
export const MAX_ABOUT_LENGTH = 140
|
export const MAX_ABOUT_LENGTH = 140
|
||||||
export const MAX_ID_LENGTH = 60
|
export const MAX_ID_LENGTH = 60
|
||||||
|
|
|
@ -31,7 +31,10 @@ import {
|
||||||
floatingLesserEqual,
|
floatingLesserEqual,
|
||||||
} from './util/math'
|
} from './util/math'
|
||||||
|
|
||||||
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
|
export type CandidateBet<T extends Bet = Bet> = Omit<
|
||||||
|
T,
|
||||||
|
'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername'
|
||||||
|
>
|
||||||
export type BetInfo = {
|
export type BetInfo = {
|
||||||
newBet: CandidateBet
|
newBet: CandidateBet
|
||||||
newPool?: { [outcome: string]: number }
|
newPool?: { [outcome: string]: number }
|
||||||
|
@ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
export const getNewMultiBetInfo = (
|
export const getNewMultiBetInfo = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
) => {
|
) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { notification_subscription_types, PrivateUser } from './user'
|
||||||
|
import { DOMAIN } from './envs/constants'
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -15,7 +18,7 @@ export type Notification = {
|
||||||
sourceUserUsername?: string
|
sourceUserUsername?: string
|
||||||
sourceUserAvatarUrl?: string
|
sourceUserAvatarUrl?: string
|
||||||
sourceText?: string
|
sourceText?: string
|
||||||
data?: string
|
data?: { [key: string]: any }
|
||||||
|
|
||||||
sourceContractTitle?: string
|
sourceContractTitle?: string
|
||||||
sourceContractCreatorUsername?: string
|
sourceContractCreatorUsername?: string
|
||||||
|
@ -51,28 +54,113 @@ export type notification_source_update_types =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
|
|
||||||
|
/* Optional - if possible use a keyof notification_subscription_types */
|
||||||
export type notification_reason_types =
|
export type notification_reason_types =
|
||||||
| 'tagged_user'
|
| 'tagged_user'
|
||||||
| 'on_users_contract'
|
|
||||||
| 'on_contract_with_users_shares_in'
|
|
||||||
| 'on_contract_with_users_shares_out'
|
|
||||||
| 'on_contract_with_users_answer'
|
|
||||||
| 'on_contract_with_users_comment'
|
|
||||||
| 'reply_to_users_answer'
|
|
||||||
| 'reply_to_users_comment'
|
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'contract_from_followed_user'
|
||||||
| 'added_you_to_group'
|
|
||||||
| 'you_referred_user'
|
| 'you_referred_user'
|
||||||
| 'user_joined_to_bet_on_your_market'
|
| 'user_joined_to_bet_on_your_market'
|
||||||
| 'unique_bettors_on_your_contract'
|
| 'unique_bettors_on_your_contract'
|
||||||
| 'on_group_you_are_member_of'
|
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
| 'you_follow_contract'
|
|
||||||
| 'liked_your_contract'
|
|
||||||
| 'liked_and_tipped_your_contract'
|
| 'liked_and_tipped_your_contract'
|
||||||
|
| 'comment_on_your_contract'
|
||||||
|
| 'answer_on_your_contract'
|
||||||
|
| 'comment_on_contract_you_follow'
|
||||||
|
| 'answer_on_contract_you_follow'
|
||||||
|
| 'update_on_contract_you_follow'
|
||||||
|
| 'resolution_on_contract_you_follow'
|
||||||
|
| 'comment_on_contract_with_users_shares_in'
|
||||||
|
| 'answer_on_contract_with_users_shares_in'
|
||||||
|
| 'update_on_contract_with_users_shares_in'
|
||||||
|
| 'resolution_on_contract_with_users_shares_in'
|
||||||
|
| 'comment_on_contract_with_users_answer'
|
||||||
|
| 'update_on_contract_with_users_answer'
|
||||||
|
| 'resolution_on_contract_with_users_answer'
|
||||||
|
| 'answer_on_contract_with_users_answer'
|
||||||
|
| 'comment_on_contract_with_users_comment'
|
||||||
|
| 'answer_on_contract_with_users_comment'
|
||||||
|
| 'update_on_contract_with_users_comment'
|
||||||
|
| 'resolution_on_contract_with_users_comment'
|
||||||
|
| 'reply_to_users_answer'
|
||||||
|
| 'reply_to_users_comment'
|
||||||
|
| 'your_contract_closed'
|
||||||
|
| 'subsidized_your_market'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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 getDestinationsForUser = async (
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
reason: notification_reason_types | keyof notification_subscription_types
|
||||||
|
) => {
|
||||||
|
const notificationSettings = privateUser.notificationPreferences
|
||||||
|
let destinations
|
||||||
|
let subscriptionType: keyof notification_subscription_types | undefined
|
||||||
|
if (Object.keys(notificationSettings).includes(reason)) {
|
||||||
|
subscriptionType = reason as keyof notification_subscription_types
|
||||||
|
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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BettingStreakData = {
|
||||||
|
streak: number
|
||||||
|
bonusAmount: number
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract'
|
||||||
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
|
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
|
||||||
import { sumBy } from 'lodash'
|
import { sumBy } from 'lodash'
|
||||||
|
|
||||||
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
export type CandidateBet<T extends Bet> = Omit<
|
||||||
|
T,
|
||||||
|
'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername'
|
||||||
|
>
|
||||||
|
|
||||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
177
common/user.ts
177
common/user.ts
|
@ -1,3 +1,5 @@
|
||||||
|
import { filterDefined } from './util/array'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -63,9 +65,63 @@ export type PrivateUser = {
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
notificationPreferences?: notification_subscribe_types
|
notificationPreferences: notification_subscription_types
|
||||||
|
twitchInfo?: {
|
||||||
|
twitchName: string
|
||||||
|
controlToken: string
|
||||||
|
botEnabled?: 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 notification_subscribe_types = 'all' | 'less' | 'none'
|
||||||
|
|
||||||
export type PortfolioMetrics = {
|
export type PortfolioMetrics = {
|
||||||
|
@ -78,3 +134,122 @@ 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 = (
|
||||||
|
userId: string,
|
||||||
|
privateUser?: PrivateUser,
|
||||||
|
noEmails?: boolean
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link'
|
||||||
import { Mention } from '@tiptap/extension-mention'
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
import TiptapTweet from './tiptap-tweet-type'
|
import TiptapTweet from './tiptap-tweet-type'
|
||||||
|
import { find } from 'linkifyjs'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
|
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
||||||
|
export function getUrl(text: string) {
|
||||||
|
const results = find(text, 'url')
|
||||||
|
return results.length ? results[0].href : null
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
const matches = (text.match(regex) || []).map((match) =>
|
const matches = (text.match(regex) || []).map((match) =>
|
||||||
|
|
|
@ -2,10 +2,30 @@
|
||||||
"functions": {
|
"functions": {
|
||||||
"predeploy": "cd functions && yarn build",
|
"predeploy": "cd functions && yarn build",
|
||||||
"runtime": "nodejs16",
|
"runtime": "nodejs16",
|
||||||
"source": "functions/dist"
|
"source": "functions/dist",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"firebase-debug.log",
|
||||||
|
"firebase-debug.*.log"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5001
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"pubsub": {
|
||||||
|
"port": 8085
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ service cloud.firestore {
|
||||||
allow read: if userId == request.auth.uid || isAdmin();
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (userId == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
|
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId}/views/{viewId} {
|
match /private-users/{userId}/views/{viewId} {
|
||||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -17,4 +17,5 @@ package-lock.json
|
||||||
ui-debug.log
|
ui-debug.log
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
firestore-debug.log
|
firestore-debug.log
|
||||||
|
pubsub-debug.log
|
||||||
firestore_export/
|
firestore_export/
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract, CPMMContract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from '../../common/antes'
|
||||||
|
import { isProd } from './utils'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||||
getNewLiquidityProvision(
|
getNewLiquidityProvision(
|
||||||
user,
|
user.id,
|
||||||
amount,
|
amount,
|
||||||
contract,
|
contract,
|
||||||
newLiquidityProvisionDoc.id
|
newLiquidityProvisionDoc.id
|
||||||
|
@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
|
||||||
|
return firestore.runTransaction(async (transaction) => {
|
||||||
|
const newLiquidityProvisionDoc = firestore
|
||||||
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
|
.doc()
|
||||||
|
|
||||||
|
const providerId = isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
|
||||||
|
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||||
|
getNewLiquidityProvision(
|
||||||
|
providerId,
|
||||||
|
amount,
|
||||||
|
contract,
|
||||||
|
newLiquidityProvisionDoc.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newP !== undefined && !isFinite(newP)) {
|
||||||
|
throw new APIError(
|
||||||
|
500,
|
||||||
|
'Liquidity injection rejected due to overflow error.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.update(
|
||||||
|
firestore.doc(`contracts/${contract.id}`),
|
||||||
|
removeUndefinedProps({
|
||||||
|
pool: newPool,
|
||||||
|
p: newP,
|
||||||
|
totalLiquidity: newTotalLiquidity,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
@ -68,10 +69,21 @@ export const changeUser = async (
|
||||||
.get()
|
.get()
|
||||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||||
|
|
||||||
|
const betsSnap = await firestore
|
||||||
|
.collectionGroup('bets')
|
||||||
|
.where('userId', '==', user.id)
|
||||||
|
.get()
|
||||||
|
const betsUpdate: Partial<Bet> = removeUndefinedProps({
|
||||||
|
userName: update.name,
|
||||||
|
userUsername: update.username,
|
||||||
|
userAvatarUrl: update.avatarUrl,
|
||||||
|
})
|
||||||
|
|
||||||
const bulkWriter = firestore.bulkWriter()
|
const bulkWriter = firestore.bulkWriter()
|
||||||
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
|
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
|
||||||
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
|
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
|
||||||
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
|
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
|
||||||
|
betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate))
|
||||||
await bulkWriter.flush()
|
await bulkWriter.flush()
|
||||||
console.log('Done writing!')
|
console.log('Done writing!')
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getNewMultiBetInfo } from '../../common/new-bet'
|
import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||||
import { getContract, getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { sendNewAnswerEmail } from './emails'
|
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
const contract = await getContract(contractId)
|
|
||||||
|
|
||||||
if (answer && contract) await sendNewAnswerEmail(answer, contract)
|
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
MAX_GROUP_NAME_LENGTH,
|
MAX_GROUP_NAME_LENGTH,
|
||||||
MAX_ID_LENGTH,
|
MAX_ID_LENGTH,
|
||||||
} from '../../common/group'
|
} from '../../common/group'
|
||||||
import { APIError, newEndpoint, validate } from '../../functions/src/api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,11 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
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 {
|
||||||
|
@ -79,6 +83,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),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
|
@ -284,9 +284,12 @@
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -491,10 +491,10 @@
|
||||||
">
|
">
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a href="{{unsubscribeLink}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a>.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -440,11 +440,10 @@
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to
|
This e-mail has been sent to
|
||||||
{{name}},
|
{{name}},
|
||||||
<a href="{{unsubscribeLink}}"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -526,19 +526,10 @@
|
||||||
"
|
"
|
||||||
>our Discord</a
|
>our Discord</a
|
||||||
>! Or,
|
>! Or,
|
||||||
<a
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
href="{{unsubscribeUrl}}"
|
color: inherit;
|
||||||
style="
|
text-decoration: none;
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
sans-serif;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
"
|
|
||||||
>unsubscribe</a
|
|
||||||
>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -485,14 +485,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
|
@ -0,0 +1,491 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market resolved</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<table class="body-wrap" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
<td class="container" width="600" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
display: block !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
clear: both !important;
|
||||||
|
margin: 0 auto;
|
||||||
|
" valign="top">
|
||||||
|
<div class="content" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 600px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #e9e9e9;
|
||||||
|
" bgcolor="#fff">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-wrap aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
width: 90%;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 40px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||||
|
alt="Manifold Markets" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 6px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
{{creatorName}} asked
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" valign="top">
|
||||||
|
<a href="{{url}}" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
'Lucida Grande', sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
color: #4337c9;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
">
|
||||||
|
{{question}}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0px;
|
||||||
|
" valign="top">
|
||||||
|
<h2 class="aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
'Lucida Grande', sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
" align="center">
|
||||||
|
Resolved {{outcome}}
|
||||||
|
</h2>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table class="invoice" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
width: 80%;
|
||||||
|
margin: 40px auto;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
" valign="top">
|
||||||
|
Dear {{name}},
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
A market you were following has been resolved!
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
Thanks,
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
Manifold Team
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="padding: 10px 0 0 0; margin: 0">
|
||||||
|
<div align="center">
|
||||||
|
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||||
|
<a href="{{url}}" target="_blank" style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: arial, helvetica, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #11b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
mso-border-alt: none;
|
||||||
|
">
|
||||||
|
<span style="
|
||||||
|
display: block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
"><span style="
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 18.8px;
|
||||||
|
">View market</span></span>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="footer" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
clear: both;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table width="100%" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="aligncenter content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
Questions? Come ask in
|
||||||
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin: 0;
|
||||||
|
">our Discord</a>! Or,
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -500,14 +500,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
<!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>New market from {{creatorName}}</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG />
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
|
<div style="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 5px 0px;padding-bottom:0px;padding-left:0px;padding-right: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="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;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" 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>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align: top" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding: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;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;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;">
|
||||||
|
{{creatorName}}, (who you're following) just created a new market, check it out!</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{questionUrl}}">
|
||||||
|
<img alt="{{questionTitle}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{questionImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{questionUrl}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 20px 0px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
">
|
||||||
|
<p style="margin: 10px 0">
|
||||||
|
This e-mail has been sent to
|
||||||
|
{{name}},
|
||||||
|
<a href="{{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;
|
||||||
|
word-break: break-word;
|
||||||
|
"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
397
functions/src/email-templates/new-unique-bettor.html
Normal file
397
functions/src/email-templates/new-unique-bettor.html
Normal file
|
@ -0,0 +1,397 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>New unique predictors on your market</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<table class="body-wrap" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
<td class="container" width="600" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
display: block !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
clear: both !important;
|
||||||
|
margin: 0 auto;
|
||||||
|
" valign="top">
|
||||||
|
<div class="content" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 600px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #e9e9e9;
|
||||||
|
" bgcolor="#fff">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-wrap aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
width: 90%;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||||
|
alt="Manifold Markets" />
|
||||||
|
</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;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;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;">
|
||||||
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
||||||
|
creating a market that appeals to others, and we'll do so for each new predictor.
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
Keep up the good work and check out your newest predictor below!
|
||||||
|
</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table class="invoice" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
width: 80%;
|
||||||
|
margin: 40px auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor1AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor1Name}}</span>
|
||||||
|
{{bet1Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="padding: 20px 0 0 0; margin: 0">
|
||||||
|
<div align="center">
|
||||||
|
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||||
|
<a href="{{marketUrl}}" target="_blank" style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: arial, helvetica, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #11b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
mso-border-alt: none;
|
||||||
|
">
|
||||||
|
<span style="
|
||||||
|
display: block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
"><span style="
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 18.8px;
|
||||||
|
">View market</span></span>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="footer" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
clear: both;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table width="100%" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="aligncenter content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
Questions? Come ask in
|
||||||
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin: 0;
|
||||||
|
">our Discord</a>! Or,
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
501
functions/src/email-templates/new-unique-bettors.html
Normal file
501
functions/src/email-templates/new-unique-bettors.html
Normal file
|
@ -0,0 +1,501 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>New unique predictors on your market</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<table class="body-wrap" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
<td class="container" width="600" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
display: block !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
clear: both !important;
|
||||||
|
margin: 0 auto;
|
||||||
|
" valign="top">
|
||||||
|
<div class="content" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 600px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #e9e9e9;
|
||||||
|
" bgcolor="#fff">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-wrap aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
width: 90%;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||||
|
alt="Manifold Markets" />
|
||||||
|
</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;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;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;">
|
||||||
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
|
||||||
|
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
Keep up the good work and check out your newest predictors below!
|
||||||
|
</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table class="invoice" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
width: 80%;
|
||||||
|
margin: 40px auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor1AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor1Name}}</span>
|
||||||
|
{{bet1Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr><tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor2AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor2Name}}</span>
|
||||||
|
{{bet2Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr><tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor3AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor3Name}}</span>
|
||||||
|
{{bet3Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr><tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor4AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor4Name}}</span>
|
||||||
|
{{bet4Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr><tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
" valign="top">
|
||||||
|
<div>
|
||||||
|
<img src="{{bettor5AvatarUrl}}" width="30" height="30" style="
|
||||||
|
border-radius: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
" alt="" />
|
||||||
|
<span style="font-weight: bold">{{bettor5Name}}</span>
|
||||||
|
{{bet5Description}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="padding: 20px 0 0 0; margin: 0">
|
||||||
|
<div align="center">
|
||||||
|
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||||
|
<a href="{{marketUrl}}" target="_blank" style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: arial, helvetica, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #11b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
mso-border-alt: none;
|
||||||
|
">
|
||||||
|
<span style="
|
||||||
|
display: block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
"><span style="
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 18.8px;
|
||||||
|
">View market</span></span>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="footer" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
clear: both;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table width="100%" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="aligncenter content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
Questions? Come ask in
|
||||||
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin: 0;
|
||||||
|
">our Discord</a>! Or,
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -1,16 +1,14 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<title>7th Day Anniversary Gift!</title>
|
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
#outlook a {
|
#outlook a {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -51,14 +49,12 @@
|
||||||
<o:AllowPNG/>
|
<o:AllowPNG/>
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
</o:OfficeDocumentSettings>
|
</o:OfficeDocumentSettings>
|
||||||
</xml> </noscript
|
</xml>
|
||||||
>z
|
</noscript>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if lte mso 11]>
|
<!--[if lte mso 11]>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.mj-outlook-group-fix {
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
@ -94,314 +90,135 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||||
<div style="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]-->
|
<!--[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
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
style="
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
background: #ffffff;
|
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="
|
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;">
|
||||||
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
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%;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
font-size: 0px;
|
width="100%">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center"
|
||||||
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;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
font-size: 0px;
|
style="border-collapse:collapse;border-spacing: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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 550px">
|
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
||||||
<a
|
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
||||||
href="https://manifold.markets/home"
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
target="_blank"
|
width="550"></a></td>
|
||||||
><img
|
|
||||||
alt=""
|
|
||||||
height="auto"
|
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.gif"
|
|
||||||
style="
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="550"
|
|
||||||
/></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="left"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
font-family: Arial, sans-serif;
|
<p class="text-build-content"
|
||||||
font-size: 18px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
letter-spacing: normal;
|
data-testid="4XoHRGw1Y"><span
|
||||||
line-height: 1;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
text-align: left;
|
Hi {{name}},</span></p>
|
||||||
color: #000000;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
style="
|
|
||||||
text-align: center;
|
|
||||||
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;
|
|
||||||
"
|
|
||||||
>Hopefully you haven't gambled all your M$
|
|
||||||
away already... but if you have I bring good
|
|
||||||
news! Click the link below to recieve a one time
|
|
||||||
gift of M$ 500 to your account!</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="center"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
<div
|
||||||
font-size: 0px;
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
padding: 10px 25px 25px 25px;
|
<p class="text-build-content"
|
||||||
padding-top: 10px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
padding-right: 25px;
|
data-testid="4XoHRGw1Y"><span
|
||||||
padding-bottom: 25px;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||||
padding-left: 25px;
|
using Manifold Markets. Running low
|
||||||
word-break: break-word;
|
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
||||||
"
|
</div>
|
||||||
>
|
</td>
|
||||||
<table
|
</tr>
|
||||||
border="0"
|
<tr>
|
||||||
cellpadding="0"
|
<td>
|
||||||
cellspacing="0"
|
<p></p>
|
||||||
role="presentation"
|
</td>
|
||||||
style="
|
</tr>
|
||||||
border-collapse: collapse;
|
<tr>
|
||||||
border-spacing: 0px;
|
<td align="center">
|
||||||
"
|
<table cellspacing="0" cellpadding="0">
|
||||||
>
|
<tr>
|
||||||
<tbody>
|
<td>
|
||||||
<tr>
|
<table cellspacing="0" cellpadding="0">
|
||||||
<td style="width: 550px">
|
<tr>
|
||||||
<a href="{{manalink}}" target="_blank">
|
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||||
<img
|
<a href="{{manalink}}" target="_blank"
|
||||||
alt="Get M$500"
|
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;">
|
||||||
height="auto"
|
Claim M$500
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png"
|
</a>
|
||||||
style="
|
</td>
|
||||||
border: none;
|
</tr>
|
||||||
display: block;
|
</table>
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="550"
|
|
||||||
/></a>
|
|
||||||
<< /td>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
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;">
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
font-family: Arial, sans-serif;
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||||
font-size: 18px;
|
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||||
letter-spacing: normal;
|
you know, besides making correct predictions, there are
|
||||||
line-height: 1;
|
plenty of other ways to earn mana?</span></p>
|
||||||
text-align: left;
|
<ul>
|
||||||
color: #000000;
|
<li style="line-height:23px;"><span
|
||||||
"
|
style="font-family:Arial, sans-serif;font-size:18px;">Predicting
|
||||||
>
|
consecutive days to earn streak rewards</span></li>
|
||||||
<p
|
<li style="line-height:23px;"><span
|
||||||
class="text-build-content"
|
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||||
style="
|
tips on comments and markets</span></li>
|
||||||
line-height: 23px;
|
<li style="line-height:23px;"><span
|
||||||
text-align: center;
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
margin: 10px 0;
|
predictor bonus for each user who predicts on your
|
||||||
margin-top: 10px;
|
markets</span></li>
|
||||||
"
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
data-testid="3Q8BP69fq"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
>
|
target="_blank" href="https://manifold.markets/referrals"><span
|
||||||
<span
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||||
style="
|
friends</u></span></a></span></li>
|
||||||
color: #000000;
|
<li style="line-height:23px;"><a class="link-build-content"
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||||
font-size: 18px;
|
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||||
"
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||||
>If you are still engaging with our markets then
|
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||||
at this point you might as well join our </span
|
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
><a
|
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://discord.gg/VARzUpyCSa"
|
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||||
><span
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||||
style="
|
feedback</u></span></a></li>
|
||||||
color: #0c21bf;
|
</ul>
|
||||||
font-family: Arial;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||||
font-size: 18px;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
"
|
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||||
><u>Discord server</u></span
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
><u>.</u>
|
|
||||||
</span></a
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>You can always leave if you dont like it but
|
|
||||||
I'd be willing to make a market betting
|
|
||||||
you'll stay.</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
class="text-build-content"
|
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||||
data-testid="3Q8BP69fq"
|
from Manifold</span></p>
|
||||||
style="margin: 10px 0"
|
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||||
></p>
|
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||||
<br />
|
|
||||||
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -415,91 +232,70 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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]-->
|
<!--[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">
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
||||||
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
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%;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
font-size: 0px;
|
width="100%">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: top; padding: 0">
|
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||||
<table
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
border="0"
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
cellpadding="0"
|
</div>
|
||||||
cellspacing="0"
|
<!--[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]-->
|
||||||
role="presentation"
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
width="100%"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
>
|
style="width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
||||||
align="center"
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
style="
|
<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;
|
font-size: 0px;
|
||||||
padding: 10px 25px;
|
padding: 10px 25px;
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<div style="
|
||||||
<div
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
style="
|
sans-serif;
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
href="{{unsubscribeLink}}"
|
|
||||||
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</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
@ -516,4 +312,5 @@
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -214,10 +214,12 @@
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent
|
||||||
to {{name}}, <a href="{{unsubscribeLink}}"
|
to {{name}},
|
||||||
style="color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to
|
color: inherit;
|
||||||
unsubscribe</a>.</p>
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -137,7 +137,7 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Welcome! Manifold Markets is a play-money prediction market platform where you can bet on
|
Welcome! Manifold Markets is a play-money prediction market platform where you can predict
|
||||||
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -286,9 +286,12 @@
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { DOMAIN } from '../../common/envs/constants'
|
import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Answer } from '../../common/answer'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Comment } from '../../common/comment'
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
notification_subscription_types,
|
||||||
|
PrivateUser,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
@ -14,15 +16,17 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { getFunctionUrl } from '../../common/api'
|
|
||||||
import { richTextToString } from '../../common/util/parse'
|
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
|
import {
|
||||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
notification_reason_types,
|
||||||
|
getDestinationsForUser,
|
||||||
|
} from '../../common/notification'
|
||||||
|
import { Dictionary } from 'lodash'
|
||||||
|
|
||||||
export const sendMarketResolutionEmail = async (
|
export const sendMarketResolutionEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
investment: number,
|
investment: number,
|
||||||
payout: number,
|
payout: number,
|
||||||
creator: User,
|
creator: User,
|
||||||
|
@ -32,15 +36,11 @@ export const sendMarketResolutionEmail = async (
|
||||||
resolutionProbability?: number,
|
resolutionProbability?: number,
|
||||||
resolutions?: { [outcome: string]: number }
|
resolutions?: { [outcome: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const user = await getUser(userId)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
const outcome = toDisplayResolution(
|
const outcome = toDisplayResolution(
|
||||||
|
@ -53,17 +53,13 @@ export const sendMarketResolutionEmail = async (
|
||||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||||
|
|
||||||
const creatorPayoutText =
|
const creatorPayoutText =
|
||||||
creatorPayout >= 1 && userId === creator.id
|
creatorPayout >= 1 && privateUser.id === creator.id
|
||||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const emailType = 'market-resolved'
|
const correctedInvestment =
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
Number.isNaN(investment) || investment < 0 ? 0 : investment
|
||||||
|
const displayedInvestment = formatMoney(correctedInvestment)
|
||||||
const displayedInvestment =
|
|
||||||
Number.isNaN(investment) || investment < 0
|
|
||||||
? formatMoney(0)
|
|
||||||
: formatMoney(investment)
|
|
||||||
|
|
||||||
const displayedPayout = formatMoney(payout)
|
const displayedPayout = formatMoney(payout)
|
||||||
|
|
||||||
|
@ -85,7 +81,7 @@ export const sendMarketResolutionEmail = async (
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-resolved',
|
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
|
||||||
templateData
|
templateData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -154,11 +150,12 @@ export const sendWelcomeEmail = async (
|
||||||
) => {
|
) => {
|
||||||
if (!privateUser || !privateUser.email) return
|
if (!privateUser || !privateUser.email) return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -166,7 +163,7 @@ export const sendWelcomeEmail = async (
|
||||||
'welcome',
|
'welcome',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold Markets one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
'creating-market',
|
'creating-market',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -282,15 +279,18 @@ export const sendThankYouEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
||||||
|
'email'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'thank_you_for_purchases' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -298,7 +298,7 @@ export const sendThankYouEmail = async (
|
||||||
'thank-you',
|
'thank-you',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -307,16 +307,15 @@ export const sendThankYouEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendMarketCloseEmail = async (
|
export const sendMarketCloseEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
if (
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
!privateUser ||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
if (!privateUser.email || !sendToEmail) return
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { username, name, id: userId } = user
|
const { username, name, id: userId } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async (
|
||||||
const { question, slug, volume } = contract
|
const { question, slug, volume } = contract
|
||||||
|
|
||||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||||
const emailType = 'market-resolve'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewCommentEmail = async (
|
export const sendNewCommentEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
commentCreator: User,
|
commentCreator: User,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
comment: Comment,
|
commentText: string,
|
||||||
|
commentId: string,
|
||||||
bet?: Bet,
|
bet?: Bet,
|
||||||
answerText?: string,
|
answerText?: string,
|
||||||
answerId?: string
|
answerId?: string
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromCommentEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question } = contract
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
|
const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
|
||||||
const emailType = 'market-comment'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||||
const { content } = comment
|
|
||||||
const text = richTextToString(content)
|
|
||||||
|
|
||||||
let betDescription = ''
|
let betDescription = ''
|
||||||
if (bet) {
|
if (bet) {
|
||||||
|
@ -380,7 +371,7 @@ export const sendNewCommentEmail = async (
|
||||||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||||
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
const answerNumber = `#${answerId}`
|
const answerNumber = answerId ? `#${answerId}` : ''
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -391,7 +382,7 @@ export const sendNewCommentEmail = async (
|
||||||
answerNumber,
|
answerNumber,
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -412,7 +403,7 @@ export const sendNewCommentEmail = async (
|
||||||
{
|
{
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -423,29 +414,24 @@ export const sendNewCommentEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewAnswerEmail = async (
|
export const sendNewAnswerEmail = async (
|
||||||
answer: Answer,
|
reason: notification_reason_types,
|
||||||
contract: Contract
|
privateUser: PrivateUser,
|
||||||
|
name: string,
|
||||||
|
text: string,
|
||||||
|
contract: Contract,
|
||||||
|
avatarUrl?: string
|
||||||
) => {
|
) => {
|
||||||
// Send to just the creator for now.
|
const { creatorId } = contract
|
||||||
const { creatorId: userId } = contract
|
|
||||||
|
|
||||||
// Don't send the creator's own answers.
|
// Don't send the creator's own answers.
|
||||||
if (answer.userId === userId) return
|
if (privateUser.id === creatorId) return
|
||||||
|
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question, creatorUsername, slug } = contract
|
||||||
const { name, avatarUrl, text } = answer
|
|
||||||
|
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
|
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
|
||||||
const emailType = 'market-answer'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const subject = `New answer on ${question}`
|
const subject = `New answer on ${question}`
|
||||||
const from = `${name} <info@manifold.markets>`
|
const from = `${name} <info@manifold.markets>`
|
||||||
|
@ -474,12 +460,13 @@ export const sendInterestingMarketsEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser?.unsubscribedFromWeeklyTrendingEmails
|
!privateUser.notificationPreferences.trending_markets.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const emailType = 'weekly-trending'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
|
'trending_markets' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -490,7 +477,7 @@ export const sendInterestingMarketsEmail = async (
|
||||||
'interesting-markets',
|
'interesting-markets',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink: unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
|
|
||||||
question1Title: contractsToSend[0].question,
|
question1Title: contractsToSend[0].question,
|
||||||
question1Link: contractUrl(contractsToSend[0]),
|
question1Link: contractUrl(contractsToSend[0]),
|
||||||
|
@ -522,3 +509,97 @@ function contractUrl(contract: Contract) {
|
||||||
function imageSourceUrl(contract: Contract) {
|
function imageSourceUrl(contract: Contract) {
|
||||||
return buildCardUrl(getOpenGraphProps(contract))
|
return buildCardUrl(getOpenGraphProps(contract))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendNewFollowedMarketEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
|
userId: string,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
contract: Contract
|
||||||
|
) => {
|
||||||
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
const creatorName = contract.creatorName
|
||||||
|
|
||||||
|
return await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
`${creatorName} asked ${contract.question}`,
|
||||||
|
'new-market-from-followed-user',
|
||||||
|
{
|
||||||
|
name: firstName,
|
||||||
|
creatorName,
|
||||||
|
unsubscribeUrl,
|
||||||
|
questionTitle: contract.question,
|
||||||
|
questionUrl: contractUrl(contract),
|
||||||
|
questionImgSrc: imageSourceUrl(contract),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: `${creatorName} on Manifold <no-reply@manifold.markets>`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const sendNewUniqueBettorsEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
|
userId: string,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
contract: Contract,
|
||||||
|
totalPredictors: number,
|
||||||
|
newPredictors: User[],
|
||||||
|
userBets: Dictionary<[Bet, ...Bet[]]>,
|
||||||
|
bonusAmount: number
|
||||||
|
) => {
|
||||||
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
const creatorName = contract.creatorName
|
||||||
|
// make the emails stack for the same contract
|
||||||
|
const subject = `You made a popular market! ${
|
||||||
|
contract.question.length > 50
|
||||||
|
? contract.question.slice(0, 50) + '...'
|
||||||
|
: contract.question
|
||||||
|
} just got ${
|
||||||
|
newPredictors.length
|
||||||
|
} new predictions. Check out who's predicting on it inside.`
|
||||||
|
const templateData: Record<string, string> = {
|
||||||
|
name: firstName,
|
||||||
|
creatorName,
|
||||||
|
totalPredictors: totalPredictors.toString(),
|
||||||
|
bonusString: formatMoney(bonusAmount),
|
||||||
|
marketTitle: contract.question,
|
||||||
|
marketUrl: contractUrl(contract),
|
||||||
|
unsubscribeUrl,
|
||||||
|
newPredictors: newPredictors.length.toString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
newPredictors.forEach((p, i) => {
|
||||||
|
templateData[`bettor${i + 1}Name`] = p.name
|
||||||
|
if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl
|
||||||
|
const bet = userBets[p.id][0]
|
||||||
|
if (bet) {
|
||||||
|
const { amount, sale } = bet
|
||||||
|
templateData[`bet${i + 1}Description`] = `${
|
||||||
|
sale || amount < 0 ? 'sold' : 'bought'
|
||||||
|
} ${formatMoney(Math.abs(amount))}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
subject,
|
||||||
|
newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors',
|
||||||
|
templateData,
|
||||||
|
{
|
||||||
|
from: `Manifold Markets <no-reply@manifold.markets>`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { acceptchallenge } from './accept-challenge'
|
import { acceptchallenge } from './accept-challenge'
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
const createPostFunction = toCloudFunction(createpost)
|
const createPostFunction = toCloudFunction(createpost)
|
||||||
|
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -119,4 +121,5 @@ export {
|
||||||
getCurrentUserFunction as getcurrentuser,
|
getCurrentUserFunction as getcurrentuser,
|
||||||
acceptChallenge as acceptchallenge,
|
acceptChallenge as acceptchallenge,
|
||||||
createPostFunction as createpost,
|
createPostFunction as createpost,
|
||||||
|
saveTwitchCredentials as savetwitchcredentials
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getUserByUsername } from './utils'
|
import { getPrivateUser, getUserByUsername } from './utils'
|
||||||
import { sendMarketCloseEmail } from './emails'
|
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
|
|
||||||
export const marketCloseNotifications = functions
|
export const marketCloseNotifications = functions
|
||||||
|
@ -56,7 +55,6 @@ async function sendMarketCloseEmails() {
|
||||||
const privateUser = await getPrivateUser(user.id)
|
const privateUser = await getPrivateUser(user.id)
|
||||||
if (!privateUser) continue
|
if (!privateUser) continue
|
||||||
|
|
||||||
await sendMarketCloseEmail(user, privateUser, contract)
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
|
|
|
@ -24,12 +24,16 @@ import {
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { APIError } from '../../common/api'
|
import { APIError } from '../../common/api'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
||||||
|
import { addHouseLiquidity } from './add-liquidity'
|
||||||
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
export const onCreateBet = functions.firestore
|
export const onCreateBet = functions
|
||||||
.document('contracts/{contractId}/bets/{betId}')
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
.firestore.document('contracts/{contractId}/bets/{betId}')
|
||||||
.onCreate(async (change, context) => {
|
.onCreate(async (change, context) => {
|
||||||
const { contractId } = context.params as {
|
const { contractId } = context.params as {
|
||||||
contractId: string
|
contractId: string
|
||||||
|
@ -58,6 +62,12 @@ export const onCreateBet = functions.firestore
|
||||||
const bettor = await getUser(bet.userId)
|
const bettor = await getUser(bet.userId)
|
||||||
if (!bettor) return
|
if (!bettor) return
|
||||||
|
|
||||||
|
await change.ref.update({
|
||||||
|
userAvatarUrl: bettor.avatarUrl,
|
||||||
|
userName: bettor.name,
|
||||||
|
userUsername: bettor.username,
|
||||||
|
})
|
||||||
|
|
||||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
|
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
|
||||||
await notifyFills(bet, contract, eventId, bettor)
|
await notifyFills(bet, contract, eventId, bettor)
|
||||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||||
|
@ -71,12 +81,16 @@ const updateBettingStreak = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
eventId: string
|
eventId: string
|
||||||
) => {
|
) => {
|
||||||
const betStreakResetTime = getTodaysBettingStreakResetTime()
|
const now = Date.now()
|
||||||
|
const currentDateResetTime = currentDateBettingStreakResetTime()
|
||||||
|
// if now is before reset time, use yesterday's reset time
|
||||||
|
const lastDateResetTime = currentDateResetTime - DAY_MS
|
||||||
|
const betStreakResetTime =
|
||||||
|
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
||||||
const lastBetTime = user?.lastBetTime ?? 0
|
const lastBetTime = user?.lastBetTime ?? 0
|
||||||
|
|
||||||
// If they've already bet after the reset time, or if we haven't hit the reset time yet
|
// If they've already bet after the reset time
|
||||||
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
|
if (lastBetTime > betStreakResetTime) return
|
||||||
return
|
|
||||||
|
|
||||||
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
||||||
// Otherwise, add 1 to their betting streak
|
// Otherwise, add 1 to their betting streak
|
||||||
|
@ -119,6 +133,7 @@ const updateBettingStreak = async (
|
||||||
bet,
|
bet,
|
||||||
contract,
|
contract,
|
||||||
bonusAmount,
|
bonusAmount,
|
||||||
|
newBettingStreak,
|
||||||
eventId
|
eventId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -148,12 +163,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
||||||
|
|
||||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
||||||
|
|
||||||
// Update contract unique bettors
|
// Update contract unique bettors
|
||||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||||
|
|
||||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
|
@ -163,6 +179,10 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
// No need to give a bonus for the creator's bet
|
// No need to give a bonus for the creator's bet
|
||||||
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
||||||
|
|
||||||
|
if (contract.mechanism === 'cpmm-1') {
|
||||||
|
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
|
||||||
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
@ -198,6 +218,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
contract,
|
contract,
|
||||||
result.txn.amount,
|
result.txn.amount,
|
||||||
|
newUniqueBettorIds,
|
||||||
eventId + '-unique-bettor-bonus'
|
eventId + '-unique-bettor-bonus'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -244,6 +265,6 @@ const notifyFills = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTodaysBettingStreakResetTime = () => {
|
const currentDateBettingStreakResetTime = () => {
|
||||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { compact, uniq } from 'lodash'
|
import { compact } from 'lodash'
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import { getContract, getUser, getValues } from './utils'
|
||||||
import { ContractComment } from '../../common/comment'
|
import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import {
|
import {
|
||||||
createCommentOrAnswerOrUpdatedContractNotification,
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
filterUserIdsForOnlyFollowerIds,
|
replied_users_info,
|
||||||
} from './create-notification'
|
} from './create-notification'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
@ -77,16 +76,46 @@ export const onCreateCommentOnContract = functions
|
||||||
const comments = await getValues<ContractComment>(
|
const comments = await getValues<ContractComment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
)
|
)
|
||||||
const relatedSourceType = comment.replyToCommentId
|
const repliedToType = answer
|
||||||
? 'comment'
|
|
||||||
: comment.answerOutcome
|
|
||||||
? 'answer'
|
? 'answer'
|
||||||
|
: comment.replyToCommentId
|
||||||
|
? 'comment'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const repliedUserId = comment.replyToCommentId
|
const repliedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
|
const mentionedUsers = compact(parseMentions(comment.content))
|
||||||
|
const repliedUsers: replied_users_info = {}
|
||||||
|
|
||||||
|
// The parent of the reply chain could be a comment or an answer
|
||||||
|
if (repliedUserId && repliedToType)
|
||||||
|
repliedUsers[repliedUserId] = {
|
||||||
|
repliedToType,
|
||||||
|
repliedToAnswerText: answer ? answer.text : undefined,
|
||||||
|
repliedToId: comment.replyToCommentId || answer?.id,
|
||||||
|
bet: bet,
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentsInSameReplyChain = comments.filter((c) =>
|
||||||
|
repliedToType === 'answer'
|
||||||
|
? c.answerOutcome === answer?.id
|
||||||
|
: repliedToType === 'comment'
|
||||||
|
? c.replyToCommentId === comment.replyToCommentId
|
||||||
|
: false
|
||||||
|
)
|
||||||
|
// The rest of the children in the chain are always comments
|
||||||
|
commentsInSameReplyChain.forEach((c) => {
|
||||||
|
if (c.userId !== comment.userId && c.userId !== repliedUserId) {
|
||||||
|
repliedUsers[c.userId] = {
|
||||||
|
repliedToType: 'comment',
|
||||||
|
repliedToAnswerText: undefined,
|
||||||
|
repliedToId: c.id,
|
||||||
|
bet: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
comment.id,
|
comment.id,
|
||||||
'comment',
|
'comment',
|
||||||
|
@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
contract,
|
contract,
|
||||||
{
|
{
|
||||||
relatedSourceType,
|
repliedUsersInfo: repliedUsers,
|
||||||
repliedUserId,
|
taggedUserIds: mentionedUsers,
|
||||||
taggedUserIds: compact(parseMentions(comment.content)),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
|
|
||||||
uniq([
|
|
||||||
contract.creatorId,
|
|
||||||
...comments.map((comment) => comment.userId),
|
|
||||||
]).filter((id) => id !== comment.userId),
|
|
||||||
contractId
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
recipientUserIds.map((userId) =>
|
|
||||||
sendNewCommentEmail(
|
|
||||||
userId,
|
|
||||||
commentCreator,
|
|
||||||
contract,
|
|
||||||
comment,
|
|
||||||
bet,
|
|
||||||
answer?.text,
|
|
||||||
answer?.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNewContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
@ -21,13 +21,11 @@ export const onCreateContract = functions
|
||||||
const mentioned = parseMentions(desc)
|
const mentioned = parseMentions(desc)
|
||||||
await addUserToContractFollowers(contract.id, contractCreator.id)
|
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNewContractNotification(
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'created',
|
|
||||||
contractCreator,
|
contractCreator,
|
||||||
|
contract,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
{ contract, recipients: mentioned }
|
mentioned
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
UNIQUE_BETTOR_LIQUIDITY_AMOUNT,
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
|
|
||||||
export const onCreateLiquidityProvision = functions.firestore
|
export const onCreateLiquidityProvision = functions.firestore
|
||||||
|
@ -17,9 +18,11 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
|
|
||||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||||
if (
|
if (
|
||||||
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
liquidity.isAnte ||
|
||||||
|
((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
|
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
|
||||||
liquidity.amount === FIXED_ANTE
|
(liquidity.amount === FIXED_ANTE ||
|
||||||
|
liquidity.amount === UNIQUE_BETTOR_LIQUIDITY_AMOUNT))
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -13,32 +13,7 @@ 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
|
||||||
if (previousValue.isResolved !== contract.isResolved) {
|
if (
|
||||||
let resolutionText = contract.resolution ?? contract.question
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
|
||||||
const answerText = contract.answers.find(
|
|
||||||
(answer) => answer.id === contract.resolution
|
|
||||||
)?.text
|
|
||||||
if (answerText) resolutionText = answerText
|
|
||||||
} else if (contract.outcomeType === 'BINARY') {
|
|
||||||
if (resolutionText === 'MKT' && contract.resolutionProbability)
|
|
||||||
resolutionText = `${contract.resolutionProbability}%`
|
|
||||||
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
|
||||||
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
|
||||||
if (resolutionText === 'MKT' && contract.resolutionValue)
|
|
||||||
resolutionText = `${contract.resolutionValue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'resolved',
|
|
||||||
contractUpdater,
|
|
||||||
eventId,
|
|
||||||
resolutionText,
|
|
||||||
contract
|
|
||||||
)
|
|
||||||
} else if (
|
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.question !== contract.question
|
previousValue.question !== contract.question
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const betDoc = contractDoc.collection('bets').doc()
|
const betDoc = contractDoc.collection('bets').doc()
|
||||||
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
trans.create(betDoc, {
|
||||||
|
id: betDoc.id,
|
||||||
|
userId: user.id,
|
||||||
|
userAvatarUrl: user.avatarUrl,
|
||||||
|
userUsername: user.username,
|
||||||
|
userName: user.name,
|
||||||
|
...newBet,
|
||||||
|
})
|
||||||
log('Created new bet document.')
|
log('Created new bet document.')
|
||||||
|
|
||||||
if (makers) {
|
if (makers) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { difference, mapValues, groupBy, sumBy } from 'lodash'
|
import { mapValues, groupBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
|
@ -8,10 +8,8 @@ import {
|
||||||
MultipleChoiceContract,
|
MultipleChoiceContract,
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, isProd, payUser } from './utils'
|
||||||
import { sendMarketResolutionEmail } from './emails'
|
|
||||||
import {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
|
@ -23,7 +21,7 @@ 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 { floatingEqual } from '../../common/util/math'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -163,15 +161,48 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
await sendResolutionEmails(
|
const userInvestments = mapValues(
|
||||||
|
groupBy(bets, (bet) => bet.userId),
|
||||||
|
(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 createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
|
contract.id,
|
||||||
|
'contract',
|
||||||
|
'resolved',
|
||||||
|
creator,
|
||||||
|
contract.id + '-resolution',
|
||||||
|
resolutionText,
|
||||||
|
contract,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
bets,
|
bets,
|
||||||
userPayoutsWithoutLoans,
|
userInvestments,
|
||||||
|
userPayouts: userPayoutsWithoutLoans,
|
||||||
creator,
|
creator,
|
||||||
creatorPayout,
|
creatorPayout,
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
resolutionProbability,
|
resolutionProbability,
|
||||||
resolutions
|
resolutions,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return updatedContract
|
return updatedContract
|
||||||
|
@ -189,51 +220,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
.then(() => ({ status: 'success' }))
|
.then(() => ({ status: 'success' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendResolutionEmails = async (
|
|
||||||
bets: Bet[],
|
|
||||||
userPayouts: { [userId: string]: number },
|
|
||||||
creator: User,
|
|
||||||
creatorPayout: number,
|
|
||||||
contract: Contract,
|
|
||||||
outcome: string,
|
|
||||||
resolutionProbability?: number,
|
|
||||||
resolutions?: { [outcome: string]: number }
|
|
||||||
) => {
|
|
||||||
const investedByUser = mapValues(
|
|
||||||
groupBy(bets, (bet) => bet.userId),
|
|
||||||
(bets) => getContractBetMetrics(contract, bets).invested
|
|
||||||
)
|
|
||||||
const investedUsers = Object.keys(investedByUser).filter(
|
|
||||||
(userId) => !floatingEqual(investedByUser[userId], 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const nonWinners = difference(investedUsers, Object.keys(userPayouts))
|
|
||||||
const emailPayouts = [
|
|
||||||
...Object.entries(userPayouts),
|
|
||||||
...nonWinners.map((userId) => [userId, 0] as const),
|
|
||||||
].map(([userId, payout]) => ({
|
|
||||||
userId,
|
|
||||||
investment: investedByUser[userId] ?? 0,
|
|
||||||
payout,
|
|
||||||
}))
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
emailPayouts.map(({ userId, investment, payout }) =>
|
|
||||||
sendMarketResolutionEmail(
|
|
||||||
userId,
|
|
||||||
investment,
|
|
||||||
payout,
|
|
||||||
creator,
|
|
||||||
creatorPayout,
|
|
||||||
contract,
|
|
||||||
outcome,
|
|
||||||
resolutionProbability,
|
|
||||||
resolutions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResolutionParams(contract: Contract, body: string) {
|
function getResolutionParams(contract: Contract, body: string) {
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
|
|
22
functions/src/save-twitch-credentials.ts
Normal file
22
functions/src/save-twitch-credentials.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { newEndpoint, validate } from './api'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
twitchInfo: z.object({
|
||||||
|
twitchName: z.string(),
|
||||||
|
controlToken: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const savetwitchcredentials = newEndpoint({}, async (req, auth) => {
|
||||||
|
const { twitchInfo } = validate(bodySchema, req.body)
|
||||||
|
const userId = auth.uid
|
||||||
|
|
||||||
|
await firestore.doc(`private-users/${userId}`).update({ twitchInfo })
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { getDefaultNotificationSettings } from 'common/user'
|
||||||
|
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
const disableEmails = !isProd()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsers.map((privateUser) => {
|
||||||
|
if (!privateUser.id) return Promise.resolve()
|
||||||
|
return firestore
|
||||||
|
.collection('private-users')
|
||||||
|
.doc(privateUser.id)
|
||||||
|
.update({
|
||||||
|
notificationPreferences: getDefaultNotificationSettings(
|
||||||
|
privateUser.id,
|
||||||
|
privateUser,
|
||||||
|
disableEmails
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
|
@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
import { PrivateUser, User } from 'common/user'
|
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
|
||||||
import { STARTING_BALANCE } from 'common/economy'
|
import { STARTING_BALANCE } from 'common/economy'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
@ -21,6 +21,7 @@ async function main() {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
|
notificationPreferences: getDefaultNotificationSettings(user.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.totalDeposits === undefined) {
|
if (user.totalDeposits === undefined) {
|
||||||
|
|
|
@ -3,12 +3,7 @@
|
||||||
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import {
|
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||||
DocumentCorrespondence,
|
|
||||||
findDiffs,
|
|
||||||
describeDiff,
|
|
||||||
applyDiff,
|
|
||||||
} from './denormalize'
|
|
||||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
@ -79,43 +74,36 @@ if (require.main === module) {
|
||||||
getAnswersByUserId(transaction),
|
getAnswersByUserId(transaction),
|
||||||
])
|
])
|
||||||
|
|
||||||
const usersContracts = Array.from(
|
const usersContracts = Array.from(usersById.entries(), ([id, doc]) => {
|
||||||
usersById.entries(),
|
return [doc, contractsByUserId.get(id) || []] as const
|
||||||
([id, doc]): DocumentCorrespondence => {
|
})
|
||||||
return [doc, contractsByUserId.get(id) || []]
|
const contractDiffs = findDiffs(usersContracts, [
|
||||||
}
|
|
||||||
)
|
|
||||||
const contractDiffs = findDiffs(
|
|
||||||
usersContracts,
|
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'creatorAvatarUrl'
|
'creatorAvatarUrl',
|
||||||
)
|
])
|
||||||
console.log(`Found ${contractDiffs.length} contracts with mismatches.`)
|
console.log(`Found ${contractDiffs.length} contracts with mismatches.`)
|
||||||
contractDiffs.forEach((d) => {
|
contractDiffs.forEach((d) => {
|
||||||
console.log(describeDiff(d))
|
console.log(describeDiff(d))
|
||||||
applyDiff(transaction, d)
|
applyDiff(transaction, d)
|
||||||
})
|
})
|
||||||
|
|
||||||
const usersComments = Array.from(
|
const usersComments = Array.from(usersById.entries(), ([id, doc]) => {
|
||||||
usersById.entries(),
|
return [doc, commentsByUserId.get(id) || []] as const
|
||||||
([id, doc]): DocumentCorrespondence => {
|
})
|
||||||
return [doc, commentsByUserId.get(id) || []]
|
const commentDiffs = findDiffs(usersComments, [
|
||||||
}
|
'avatarUrl',
|
||||||
)
|
'userAvatarUrl',
|
||||||
const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl')
|
])
|
||||||
console.log(`Found ${commentDiffs.length} comments with mismatches.`)
|
console.log(`Found ${commentDiffs.length} comments with mismatches.`)
|
||||||
commentDiffs.forEach((d) => {
|
commentDiffs.forEach((d) => {
|
||||||
console.log(describeDiff(d))
|
console.log(describeDiff(d))
|
||||||
applyDiff(transaction, d)
|
applyDiff(transaction, d)
|
||||||
})
|
})
|
||||||
|
|
||||||
const usersAnswers = Array.from(
|
const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => {
|
||||||
usersById.entries(),
|
return [doc, answersByUserId.get(id) || []] as const
|
||||||
([id, doc]): DocumentCorrespondence => {
|
})
|
||||||
return [doc, answersByUserId.get(id) || []]
|
const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl'])
|
||||||
}
|
|
||||||
)
|
|
||||||
const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl')
|
|
||||||
console.log(`Found ${answerDiffs.length} answers with mismatches.`)
|
console.log(`Found ${answerDiffs.length} answers with mismatches.`)
|
||||||
answerDiffs.forEach((d) => {
|
answerDiffs.forEach((d) => {
|
||||||
console.log(describeDiff(d))
|
console.log(describeDiff(d))
|
||||||
|
|
38
functions/src/scripts/denormalize-bet-user-data.ts
Normal file
38
functions/src/scripts/denormalize-bet-user-data.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Filling in the user-based fields on bets.
|
||||||
|
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { findDiffs, describeDiff, getDiffUpdate } from './denormalize'
|
||||||
|
import { log, writeAsync } from '../utils'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
// not in a transaction for speed -- may need to be run more than once
|
||||||
|
async function denormalize() {
|
||||||
|
const users = await firestore.collection('users').get()
|
||||||
|
log(`Found ${users.size} users.`)
|
||||||
|
for (const userDoc of users.docs) {
|
||||||
|
const userBets = await firestore
|
||||||
|
.collectionGroup('bets')
|
||||||
|
.where('userId', '==', userDoc.id)
|
||||||
|
.get()
|
||||||
|
const mapping = [[userDoc, userBets.docs] as const] as const
|
||||||
|
const diffs = findDiffs(
|
||||||
|
mapping,
|
||||||
|
['avatarUrl', 'userAvatarUrl'],
|
||||||
|
['name', 'userName'],
|
||||||
|
['username', 'userUsername']
|
||||||
|
)
|
||||||
|
log(`Found ${diffs.length} bets with mismatched user data.`)
|
||||||
|
const updates = diffs.map((d) => {
|
||||||
|
log(describeDiff(d))
|
||||||
|
return getDiffUpdate(d)
|
||||||
|
})
|
||||||
|
await writeAsync(firestore, updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
denormalize().catch((e) => console.error(e))
|
||||||
|
}
|
|
@ -3,12 +3,7 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { zip } from 'lodash'
|
import { zip } from 'lodash'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import {
|
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||||
DocumentCorrespondence,
|
|
||||||
findDiffs,
|
|
||||||
describeDiff,
|
|
||||||
applyDiff,
|
|
||||||
} from './denormalize'
|
|
||||||
import { log } from '../utils'
|
import { log } from '../utils'
|
||||||
import { Transaction } from 'firebase-admin/firestore'
|
import { Transaction } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
|
@ -41,17 +36,20 @@ async function denormalize() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
log(`Found ${bets.length} bets associated with comments.`)
|
log(`Found ${bets.length} bets associated with comments.`)
|
||||||
const mapping = zip(bets, betComments)
|
|
||||||
.map(([bet, comment]): DocumentCorrespondence => {
|
|
||||||
return [bet!, [comment!]] // eslint-disable-line
|
|
||||||
})
|
|
||||||
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
|
|
||||||
|
|
||||||
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
|
// dev DB has some invalid bet IDs
|
||||||
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
|
const mapping = zip(bets, betComments)
|
||||||
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
|
.filter(([bet, _]) => bet!.exists) // eslint-disable-line
|
||||||
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
|
.map(([bet, comment]) => {
|
||||||
const diffs = amountDiffs.concat(outcomeDiffs)
|
return [bet!, [comment!]] as const // eslint-disable-line
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffs = findDiffs(
|
||||||
|
mapping,
|
||||||
|
['amount', 'betAmount'],
|
||||||
|
['outcome', 'betOutcome']
|
||||||
|
)
|
||||||
|
log(`Found ${diffs.length} comments with mismatched data.`)
|
||||||
diffs.slice(0, 500).forEach((d) => {
|
diffs.slice(0, 500).forEach((d) => {
|
||||||
log(describeDiff(d))
|
log(describeDiff(d))
|
||||||
applyDiff(trans, d)
|
applyDiff(trans, d)
|
||||||
|
|
|
@ -2,12 +2,7 @@
|
||||||
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import {
|
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||||
DocumentCorrespondence,
|
|
||||||
findDiffs,
|
|
||||||
describeDiff,
|
|
||||||
applyDiff,
|
|
||||||
} from './denormalize'
|
|
||||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
@ -43,16 +38,15 @@ async function denormalize() {
|
||||||
getContractsById(transaction),
|
getContractsById(transaction),
|
||||||
getCommentsByContractId(transaction),
|
getCommentsByContractId(transaction),
|
||||||
])
|
])
|
||||||
const mapping = Object.entries(contractsById).map(
|
const mapping = Object.entries(contractsById).map(([id, doc]) => {
|
||||||
([id, doc]): DocumentCorrespondence => {
|
return [doc, commentsByContractId.get(id) || []] as const
|
||||||
return [doc, commentsByContractId.get(id) || []]
|
})
|
||||||
}
|
const diffs = findDiffs(
|
||||||
|
mapping,
|
||||||
|
['slug', 'contractSlug'],
|
||||||
|
['question', 'contractQuestion']
|
||||||
)
|
)
|
||||||
const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug')
|
console.log(`Found ${diffs.length} comments with mismatched data.`)
|
||||||
const qDiffs = findDiffs(mapping, 'question', 'contractQuestion')
|
|
||||||
console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`)
|
|
||||||
console.log(`Found ${qDiffs.length} comments with mismatched questions.`)
|
|
||||||
const diffs = slugDiffs.concat(qDiffs)
|
|
||||||
diffs.slice(0, 500).forEach((d) => {
|
diffs.slice(0, 500).forEach((d) => {
|
||||||
console.log(describeDiff(d))
|
console.log(describeDiff(d))
|
||||||
applyDiff(transaction, d)
|
applyDiff(transaction, d)
|
||||||
|
|
|
@ -2,32 +2,40 @@
|
||||||
// another set of documents.
|
// another set of documents.
|
||||||
|
|
||||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||||
|
import { isEqual, zip } from 'lodash'
|
||||||
|
import { UpdateSpec } from '../utils'
|
||||||
|
|
||||||
export type DocumentValue = {
|
export type DocumentValue = {
|
||||||
doc: DocumentSnapshot
|
doc: DocumentSnapshot
|
||||||
field: string
|
fields: string[]
|
||||||
val: unknown
|
vals: unknown[]
|
||||||
}
|
}
|
||||||
export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]]
|
export type DocumentMapping = readonly [
|
||||||
|
DocumentSnapshot,
|
||||||
|
readonly DocumentSnapshot[]
|
||||||
|
]
|
||||||
export type DocumentDiff = {
|
export type DocumentDiff = {
|
||||||
src: DocumentValue
|
src: DocumentValue
|
||||||
dest: DocumentValue
|
dest: DocumentValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PathPair = readonly [string, string]
|
||||||
|
|
||||||
export function findDiffs(
|
export function findDiffs(
|
||||||
docs: DocumentCorrespondence[],
|
docs: readonly DocumentMapping[],
|
||||||
srcPath: string,
|
...paths: PathPair[]
|
||||||
destPath: string
|
|
||||||
) {
|
) {
|
||||||
const diffs: DocumentDiff[] = []
|
const diffs: DocumentDiff[] = []
|
||||||
|
const srcPaths = paths.map((p) => p[0])
|
||||||
|
const destPaths = paths.map((p) => p[1])
|
||||||
for (const [srcDoc, destDocs] of docs) {
|
for (const [srcDoc, destDocs] of docs) {
|
||||||
const srcVal = srcDoc.get(srcPath)
|
const srcVals = srcPaths.map((p) => srcDoc.get(p))
|
||||||
for (const destDoc of destDocs) {
|
for (const destDoc of destDocs) {
|
||||||
const destVal = destDoc.get(destPath)
|
const destVals = destPaths.map((p) => destDoc.get(p))
|
||||||
if (destVal !== srcVal) {
|
if (!isEqual(srcVals, destVals)) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
src: { doc: srcDoc, field: srcPath, val: srcVal },
|
src: { doc: srcDoc, fields: srcPaths, vals: srcVals },
|
||||||
dest: { doc: destDoc, field: destPath, val: destVal },
|
dest: { doc: destDoc, fields: destPaths, vals: destVals },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,12 +45,19 @@ export function findDiffs(
|
||||||
|
|
||||||
export function describeDiff(diff: DocumentDiff) {
|
export function describeDiff(diff: DocumentDiff) {
|
||||||
function describeDocVal(x: DocumentValue): string {
|
function describeDocVal(x: DocumentValue): string {
|
||||||
return `${x.doc.ref.path}.${x.field}: ${x.val}`
|
return `${x.doc.ref.path}.[${x.fields.join('|')}]: [${x.vals.join('|')}]`
|
||||||
}
|
}
|
||||||
return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}`
|
return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
export function getDiffUpdate(diff: DocumentDiff) {
|
||||||
const { src, dest } = diff
|
return {
|
||||||
transaction.update(dest.doc.ref, dest.field, src.val)
|
doc: diff.dest.doc.ref,
|
||||||
|
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
|
||||||
|
} as UpdateSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
||||||
|
const update = getDiffUpdate(diff)
|
||||||
|
transaction.update(update.doc, update.fields)
|
||||||
}
|
}
|
||||||
|
|
25
functions/src/scripts/update-notification-preferences.ts
Normal file
25
functions/src/scripts/update-notification-preferences.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||||
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsers.map((privateUser) => {
|
||||||
|
if (!privateUser.id) return Promise.resolve()
|
||||||
|
return firestore.collection('private-users').doc(privateUser.id).update({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
notificationPreferences: privateUser.notificationSubscriptionTypes,
|
||||||
|
notificationSubscriptionTypes: FieldValue.delete(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
|
@ -112,6 +112,9 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
transaction.create(newBetDoc, {
|
transaction.create(newBetDoc, {
|
||||||
id: newBetDoc.id,
|
id: newBetDoc.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
userAvatarUrl: user.avatarUrl,
|
||||||
|
userUsername: user.username,
|
||||||
|
userName: user.name,
|
||||||
...newBet,
|
...newBet,
|
||||||
})
|
})
|
||||||
transaction.update(
|
transaction.update(
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
|
||||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
const app = express()
|
const app = express()
|
||||||
|
@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
|
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
addEndpointRoute('/createpost', createpost)
|
addEndpointRoute('/createpost', createpost)
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract, CPMM } from '../../common/contract'
|
import { Contract, CPMM } from '../../common/contract'
|
||||||
|
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
import { scoreTraders, scoreCreators } from '../../common/scoring'
|
||||||
import {
|
import {
|
||||||
calculateCreatorVolume,
|
calculateCreatorVolume,
|
||||||
calculateNewPortfolioMetrics,
|
calculateNewPortfolioMetrics,
|
||||||
|
@ -15,6 +17,7 @@ import {
|
||||||
computeVolume,
|
computeVolume,
|
||||||
} from '../../common/calculate-metrics'
|
} from '../../common/calculate-metrics'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -24,7 +27,8 @@ export const updateMetrics = functions
|
||||||
.onRun(updateMetricsCore)
|
.onRun(updateMetricsCore)
|
||||||
|
|
||||||
export async function updateMetricsCore() {
|
export async function updateMetricsCore() {
|
||||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
const [users, contracts, bets, allPortfolioHistories, groups] =
|
||||||
|
await Promise.all([
|
||||||
getValues<User>(firestore.collection('users')),
|
getValues<User>(firestore.collection('users')),
|
||||||
getValues<Contract>(firestore.collection('contracts')),
|
getValues<Contract>(firestore.collection('contracts')),
|
||||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||||
|
@ -33,7 +37,19 @@ export async function updateMetricsCore() {
|
||||||
.collectionGroup('portfolioHistory')
|
.collectionGroup('portfolioHistory')
|
||||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||||
),
|
),
|
||||||
|
getValues<Group>(firestore.collection('groups')),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const contractsByGroup = await Promise.all(
|
||||||
|
groups.map((group) => {
|
||||||
|
return getValues(
|
||||||
|
firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(group.id)
|
||||||
|
.collection('groupContracts')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
log(
|
log(
|
||||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||||
)
|
)
|
||||||
|
@ -41,6 +57,7 @@ export async function updateMetricsCore() {
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||||
|
|
||||||
const contractUpdates = contracts
|
const contractUpdates = contracts
|
||||||
.filter((contract) => contract.id)
|
.filter((contract) => contract.id)
|
||||||
.map((contract) => {
|
.map((contract) => {
|
||||||
|
@ -162,4 +179,48 @@ export async function updateMetricsCore() {
|
||||||
'set'
|
'set'
|
||||||
)
|
)
|
||||||
log(`Updated metrics for ${users.length} users.`)
|
log(`Updated metrics for ${users.length} users.`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupUpdates = groups.map((group, index) => {
|
||||||
|
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||||
|
const groupContracts = groupContractIds
|
||||||
|
.map((e) => contractsById[e.contractId])
|
||||||
|
.filter((e) => e !== undefined) as Contract[]
|
||||||
|
const bets = groupContracts.map((e) => {
|
||||||
|
if (e != null && e.id in betsByContract) {
|
||||||
|
return betsByContract[e.id] ?? []
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const creatorScores = scoreCreators(groupContracts)
|
||||||
|
const traderScores = scoreTraders(groupContracts, bets)
|
||||||
|
|
||||||
|
const topTraderScores = topUserScores(traderScores)
|
||||||
|
const topCreatorScores = topUserScores(creatorScores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc: firestore.collection('groups').doc(group.id),
|
||||||
|
fields: {
|
||||||
|
cachedLeaderboard: {
|
||||||
|
topTraders: topTraderScores,
|
||||||
|
topCreators: topCreatorScores,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await writeAsync(firestore, groupUpdates)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error While Updating Group Leaderboards', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topUserScores = (scores: { [userId: string]: number }) => {
|
||||||
|
const top50 = Object.entries(scores)
|
||||||
|
.sort(([, scoreA], [, scoreB]) => scoreB - scoreA)
|
||||||
|
.slice(0, 50)
|
||||||
|
return top50.map(([userId, score]) => ({ userId, score }))
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
|
@ -1,236 +0,0 @@
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
|
||||||
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { track } from '@amplitude/analytics-browser'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
||||||
import { Row } from 'web/components/layout/row'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
|
||||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
|
||||||
|
|
||||||
export function NotificationSettings() {
|
|
||||||
const user = useUser()
|
|
||||||
const [notificationSettings, setNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
|
||||||
const [showModal, setShowModal] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!privateUser) return
|
|
||||||
if (privateUser.notificationPreferences) {
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('none')
|
|
||||||
} else if (
|
|
||||||
!privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
!privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
!privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('all')
|
|
||||||
} else {
|
|
||||||
setEmailNotificationSettings('less')
|
|
||||||
}
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
const loading = 'Changing Notifications Settings'
|
|
||||||
const success = 'Notification Settings Changed!'
|
|
||||||
function changeEmailNotifications(newValue: notification_subscribe_types) {
|
|
||||||
if (!privateUser) return
|
|
||||||
if (newValue === 'all') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: false,
|
|
||||||
unsubscribedFromAnswerEmails: false,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'less') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'none') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: true,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeInAppNotificationSettings(
|
|
||||||
newValue: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (!privateUser) return
|
|
||||||
track('In-App Notification Preferences Changed', {
|
|
||||||
newPreference: newValue,
|
|
||||||
oldPreference: privateUser.notificationPreferences,
|
|
||||||
})
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
notificationPreferences: newValue,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (privateUser && privateUser.notificationPreferences)
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
else setNotificationSettings('all')
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
if (!privateUser) {
|
|
||||||
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
|
||||||
label: string | React.ReactNode
|
|
||||||
highlight: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
}) {
|
|
||||||
const { label, highlight, onClick } = props
|
|
||||||
return (
|
|
||||||
<Row
|
|
||||||
className={clsx(
|
|
||||||
'my-1 gap-1 text-gray-300',
|
|
||||||
highlight && '!text-black',
|
|
||||||
onClick ? 'cursor-pointer' : ''
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
|
||||||
{label}
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'p-2'}>
|
|
||||||
<div>In App Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={notificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeInAppNotificationSettings(
|
|
||||||
choice as notification_subscribe_types
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4 text-sm'}>
|
|
||||||
<Col className={''}>
|
|
||||||
<Row className={'my-1'}>
|
|
||||||
You will receive notifications for these general events:
|
|
||||||
</Row>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={"Income & referral bonuses you've received"}
|
|
||||||
/>
|
|
||||||
<Row className={'my-1'}>
|
|
||||||
You will receive new comment, answer, & resolution notifications on
|
|
||||||
questions:
|
|
||||||
</Row>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={
|
|
||||||
<span>
|
|
||||||
That <span className={'font-bold'}>you watch </span>- you
|
|
||||||
auto-watch questions if:
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
/>
|
|
||||||
<Col
|
|
||||||
className={clsx(
|
|
||||||
'mb-2 ml-8',
|
|
||||||
'gap-1 text-gray-300',
|
|
||||||
notificationSettings !== 'none' && '!text-black'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Row>• You create it</Row>
|
|
||||||
<Row>• You bet, comment on, or answer it</Row>
|
|
||||||
<Row>• You add liquidity to it</Row>
|
|
||||||
<Row>
|
|
||||||
• If you select 'Less' and you've commented on or answered a
|
|
||||||
question, you'll only receive notification on direct replies to
|
|
||||||
your comments or answers
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={emailNotificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeEmailNotifications(choice as notification_subscribe_types)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4 text-sm'}>
|
|
||||||
<div>
|
|
||||||
You will receive emails for:
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Resolution of questions you're betting on"}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Closure of your questions'}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Activity on your questions'}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Activity on questions you've answered or commented on"}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { Linkify } from 'web/components/linkify'
|
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'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
@ -30,14 +31,15 @@ export function AnswersPanel(props: {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||||
contract
|
contract
|
||||||
|
const [showAllAnswers, setShowAllAnswers] = useState(false)
|
||||||
|
|
||||||
|
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
||||||
|
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
)
|
||||||
|
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||||
|
|
||||||
const answers = useAnswers(contract.id) ?? contract.answers
|
|
||||||
const [winningAnswers, losingAnswers] = partition(
|
const [winningAnswers, losingAnswers] = partition(
|
||||||
answers.filter(
|
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
|
||||||
(answer) =>
|
|
||||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
|
||||||
totalBets[answer.id] > 0.000000001
|
|
||||||
),
|
|
||||||
(answer) =>
|
(answer) =>
|
||||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||||
)
|
)
|
||||||
|
@ -127,6 +129,17 @@ export function AnswersPanel(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Row className={'justify-end'}>
|
||||||
|
{hasZeroBetAnswers && !showAllAnswers && (
|
||||||
|
<Button
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={() => setShowAllAnswers(true)}
|
||||||
|
size={'md'}
|
||||||
|
>
|
||||||
|
Show More
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
if (existingAnswer) {
|
if (existingAnswer) {
|
||||||
setAnswerError(
|
setAnswerError(
|
||||||
existingAnswer
|
existingAnswer
|
||||||
? `"${existingAnswer.text}" already exists as an answer`
|
? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
|
||||||
}[level] ?? ''
|
}[level] ?? ''
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`}
|
className={`${colorClass} mb-2 mr-auto self-center text-xs font-medium tracking-wide`}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
|
||||||
if (!isArray(sections)) sections = []
|
if (!isArray(sections)) sections = []
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'Daily movers', id: 'daily-movers' },
|
|
||||||
{ label: 'Trending', id: 'score' },
|
{ label: 'Trending', id: 'score' },
|
||||||
{ label: 'New for you', id: 'newest' },
|
{ label: 'New for you', id: 'newest' },
|
||||||
|
{ label: 'Daily movers', id: 'daily-movers' },
|
||||||
...groups.map((g) => ({
|
...groups.map((g) => ({
|
||||||
label: g.name,
|
label: g.name,
|
||||||
id: g.id,
|
id: g.id,
|
||||||
|
|
|
@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector'
|
||||||
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
import { isAndroid, isIOS } from 'web/lib/util/device'
|
import { isAndroid, isIOS } from 'web/lib/util/device'
|
||||||
import { WarningConfirmationButton } from './warning-confirmation-button'
|
import { WarningConfirmationButton } from './warning-confirmation-button'
|
||||||
|
import { MarketIntroPanel } from './market-intro-panel'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -90,10 +91,7 @@ export function BetPanel(props: {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<MarketIntroPanel />
|
||||||
<BetSignUpPrompt />
|
|
||||||
<PlayMoneyDisclaimer />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|
|
@ -200,7 +200,7 @@ export function ContractSearch(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="h-full">
|
<Col>
|
||||||
<ContractSearchControls
|
<ContractSearchControls
|
||||||
className={headerClassName}
|
className={headerClassName}
|
||||||
defaultSort={defaultSort}
|
defaultSort={defaultSort}
|
||||||
|
|
102
web/components/contract-select-modal.tsx
Normal file
102
web/components/contract-select-modal.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { ContractSearch } from './contract-search'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { LoadingIndicator } from './loading-indicator'
|
||||||
|
|
||||||
|
export function SelectMarketsModal(props: {
|
||||||
|
title: string
|
||||||
|
description?: React.ReactNode
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
submitLabel: (length: number) => string
|
||||||
|
onSubmit: (contracts: Contract[]) => void | Promise<void>
|
||||||
|
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
contractSearchOptions,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [contracts, setContracts] = useState<Contract[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function addContract(contract: Contract) {
|
||||||
|
if (contracts.map((c) => c.id).includes(contract.id)) {
|
||||||
|
setContracts(contracts.filter((c) => c.id !== contract.id))
|
||||||
|
} else setContracts([...contracts, contract])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFinish() {
|
||||||
|
setLoading(true)
|
||||||
|
await onSubmit(contracts)
|
||||||
|
setLoading(false)
|
||||||
|
setOpen(false)
|
||||||
|
setContracts([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
||||||
|
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
|
||||||
|
<div className="p-8 pb-0">
|
||||||
|
<Row>
|
||||||
|
<div className={'text-xl text-indigo-700'}>{title}</div>
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<Row className="grow justify-end gap-4">
|
||||||
|
{contracts.length > 0 && (
|
||||||
|
<Button onClick={onFinish} color="indigo">
|
||||||
|
{submitLabel(contracts.length)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (contracts.length > 0) {
|
||||||
|
setContracts([])
|
||||||
|
} else {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
{contracts.length > 0 ? 'Reset' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="w-full justify-center">
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-y-auto sm:px-8">
|
||||||
|
<ContractSearch
|
||||||
|
hideOrderSelector
|
||||||
|
onContractClick={addContract}
|
||||||
|
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||||
|
highlightOptions={{
|
||||||
|
contractIds: contracts.map((c) => c.id),
|
||||||
|
highlightClassName:
|
||||||
|
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||||
|
}}
|
||||||
|
additionalFilter={{}} /* hide pills */
|
||||||
|
headerClassName="bg-white"
|
||||||
|
{...contractSearchOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={`${formatMoney(
|
text={`${formatMoney(
|
||||||
volume
|
volume
|
||||||
)} bet - ${uniqueBettors} unique traders`}
|
)} bet - ${uniqueBettors} unique predictors`}
|
||||||
>
|
>
|
||||||
{volumeTranslation}
|
{volumeTranslation}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { useUserById } from 'web/hooks/use-user'
|
|
||||||
import { listUsers, User } from 'web/lib/firebase/users'
|
import { listUsers, User } from 'web/lib/firebase/users'
|
||||||
import { FeedBet } from '../feed/feed-bets'
|
import { FeedBet } from '../feed/feed-bets'
|
||||||
import { FeedComment } from '../feed/feed-comments'
|
import { FeedComment } from '../feed/feed-comments'
|
||||||
|
@ -88,7 +87,7 @@ export function ContractTopTrades(props: {
|
||||||
|
|
||||||
// Now find the betId with the highest profit
|
// Now find the betId with the highest profit
|
||||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||||
const topBettor = useUserById(betsById[topBetId]?.userId)
|
const topBettor = betsById[topBetId]?.userName
|
||||||
|
|
||||||
// And also the commentId of the comment with the highest profit
|
// And also the commentId of the comment with the highest profit
|
||||||
const topCommentId = sortBy(
|
const topCommentId = sortBy(
|
||||||
|
@ -121,7 +120,7 @@ export function ContractTopTrades(props: {
|
||||||
<FeedBet contract={contract} bet={betsById[topBetId]} />
|
<FeedBet contract={contract} bet={betsById[topBetId]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 ml-2 text-sm text-gray-500">
|
<div className="mt-2 ml-2 text-sm text-gray-500">
|
||||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
{topBettor} made {formatMoney(profitById[topBetId] || 0)}!
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export const FollowMarketModal = (props: {
|
export const WatchMarketModal = (props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (b: boolean) => void
|
setOpen: (b: boolean) => void
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -18,20 +18,21 @@ export const FollowMarketModal = (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 can receive notifications on questions you're interested in by
|
You'll receive notifications on markets by betting, commenting, or
|
||||||
clicking the
|
clicking 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 a question.
|
️ button on them.
|
||||||
</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 in-app notifications for new comments, answers, and
|
You'll receive notifications for new comments, answers, and updates
|
||||||
updates to the question.
|
to the question. See the notifications settings pages to customize
|
||||||
|
which types of notifications you receive on watched markets.
|
||||||
</span>
|
</span>
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
|
@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
|
BubbleMenu,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
|
@ -24,13 +25,19 @@ 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'
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
PhotographIcon,
|
PhotographIcon,
|
||||||
PresentationChartLineIcon,
|
PresentationChartLineIcon,
|
||||||
|
TrashIcon,
|
||||||
} from '@heroicons/react/solid'
|
} from '@heroicons/react/solid'
|
||||||
import { MarketModal } from './editor/market-modal'
|
import { MarketModal } from './editor/market-modal'
|
||||||
import { insertContent } from './editor/utils'
|
import { insertContent } from './editor/utils'
|
||||||
import { Tooltip } from './tooltip'
|
import { Tooltip } from './tooltip'
|
||||||
|
import BoldIcon from 'web/lib/icons/bold-icon'
|
||||||
|
import ItalicIcon from 'web/lib/icons/italic-icon'
|
||||||
|
import LinkIcon from 'web/lib/icons/link-icon'
|
||||||
|
import { getUrl } from 'common/util/parse'
|
||||||
|
|
||||||
const DisplayImage = Image.configure({
|
const DisplayImage = Image.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
|
@ -141,6 +148,66 @@ function isValidIframe(text: string) {
|
||||||
return /^<iframe.*<\/iframe>$/.test(text)
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FloatingMenu(props: { editor: Editor | null }) {
|
||||||
|
const { editor } = props
|
||||||
|
|
||||||
|
const [url, setUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
// current selection
|
||||||
|
const isBold = editor.isActive('bold')
|
||||||
|
const isItalic = editor.isActive('italic')
|
||||||
|
const isLink = editor.isActive('link')
|
||||||
|
|
||||||
|
const setLink = () => {
|
||||||
|
const href = url && getUrl(url)
|
||||||
|
if (href) {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsetLink = () => editor.chain().focus().unsetLink().run()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white"
|
||||||
|
>
|
||||||
|
{url === null ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<ItalicIcon
|
||||||
|
className={clsx('h-5', isItalic && 'text-indigo-200')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
|
||||||
|
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0"
|
||||||
|
placeholder="Type or paste a link"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => (setLink(), setUrl(null))}>
|
||||||
|
<CheckIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => (unsetLink(), setUrl(null))}>
|
||||||
|
<TrashIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BubbleMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
|
@ -155,6 +222,7 @@ export function TextEditor(props: {
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
|
<FloatingMenu editor={editor} />
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{/* Toolbar, with buttons for images and embeds */}
|
{/* Toolbar, with buttons for images and embeds */}
|
||||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { useState } from 'react'
|
import { SelectMarketsModal } from '../contract-select-modal'
|
||||||
import { Button } from '../button'
|
|
||||||
import { ContractSearch } from '../contract-search'
|
|
||||||
import { Col } from '../layout/col'
|
|
||||||
import { Modal } from '../layout/modal'
|
|
||||||
import { Row } from '../layout/row'
|
|
||||||
import { LoadingIndicator } from '../loading-indicator'
|
|
||||||
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
||||||
import { insertContent } from './utils'
|
import { insertContent } from './utils'
|
||||||
|
|
||||||
|
@ -17,83 +11,23 @@ export function MarketModal(props: {
|
||||||
}) {
|
}) {
|
||||||
const { editor, open, setOpen } = props
|
const { editor, open, setOpen } = props
|
||||||
|
|
||||||
const [contracts, setContracts] = useState<Contract[]>([])
|
function onSubmit(contracts: Contract[]) {
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
async function addContract(contract: Contract) {
|
|
||||||
if (contracts.map((c) => c.id).includes(contract.id)) {
|
|
||||||
setContracts(contracts.filter((c) => c.id !== contract.id))
|
|
||||||
} else setContracts([...contracts, contract])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doneAddingContracts() {
|
|
||||||
setLoading(true)
|
|
||||||
if (contracts.length == 1) {
|
if (contracts.length == 1) {
|
||||||
insertContent(editor, embedContractCode(contracts[0]))
|
insertContent(editor, embedContractCode(contracts[0]))
|
||||||
} else if (contracts.length > 1) {
|
} else if (contracts.length > 1) {
|
||||||
insertContent(editor, embedContractGridCode(contracts))
|
insertContent(editor, embedContractGridCode(contracts))
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
setOpen(false)
|
|
||||||
setContracts([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
<SelectMarketsModal
|
||||||
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
|
title="Embed markets"
|
||||||
<Row className="p-8 pb-0">
|
open={open}
|
||||||
<div className={'text-xl text-indigo-700'}>Embed a market</div>
|
setOpen={setOpen}
|
||||||
|
submitLabel={(len) =>
|
||||||
{!loading && (
|
len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions`
|
||||||
<Row className="grow justify-end gap-4">
|
|
||||||
{contracts.length == 1 && (
|
|
||||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
|
||||||
Embed 1 question
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{contracts.length > 1 && (
|
|
||||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
|
||||||
Embed grid of {contracts.length} question
|
|
||||||
{contracts.length > 1 && 's'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (contracts.length > 0) {
|
|
||||||
setContracts([])
|
|
||||||
} else {
|
|
||||||
setOpen(false)
|
|
||||||
}
|
}
|
||||||
}}
|
onSubmit={onSubmit}
|
||||||
color="gray"
|
|
||||||
>
|
|
||||||
{contracts.length > 0 ? 'Reset' : 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="w-full justify-center">
|
|
||||||
<LoadingIndicator />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-y-scroll sm:px-8">
|
|
||||||
<ContractSearch
|
|
||||||
hideOrderSelector
|
|
||||||
onContractClick={addContract}
|
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
|
||||||
highlightOptions={{
|
|
||||||
contractIds: contracts.map((c) => c.id),
|
|
||||||
highlightClassName:
|
|
||||||
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
|
||||||
}}
|
|
||||||
additionalFilter={{}} /* hide pills */
|
|
||||||
headerClassName="bg-white"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { User } from 'common/user'
|
import { useUser } 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'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
@ -18,29 +17,20 @@ import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
||||||
const { contract, bet } = props
|
const { contract, bet } = props
|
||||||
const { userId, createdTime } = bet
|
const { userAvatarUrl, userUsername, createdTime } = bet
|
||||||
|
const showUser = dayjs(createdTime).isAfter('2022-06-01')
|
||||||
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const bettor = isBeforeJune2022 ? undefined : useUserById(userId)
|
|
||||||
|
|
||||||
const user = useUser()
|
|
||||||
const isSelf = user?.id === userId
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2 pt-3">
|
<Row className="items-center gap-2 pt-3">
|
||||||
{isSelf ? (
|
{showUser ? (
|
||||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
<Avatar avatarUrl={userAvatarUrl} username={userUsername} />
|
||||||
) : bettor ? (
|
|
||||||
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyAvatar className="mx-1" />
|
<EmptyAvatar className="mx-1" />
|
||||||
)}
|
)}
|
||||||
<BetStatusText
|
<BetStatusText
|
||||||
bet={bet}
|
bet={bet}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isSelf={isSelf}
|
hideUser={!showUser}
|
||||||
bettor={bettor}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -50,13 +40,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
||||||
export function BetStatusText(props: {
|
export function BetStatusText(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bet: Bet
|
bet: Bet
|
||||||
isSelf: boolean
|
hideUser?: boolean
|
||||||
bettor?: User
|
|
||||||
hideOutcome?: boolean
|
hideOutcome?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
|
const { bet, contract, hideUser, hideOutcome, className } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
const self = useUser()
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||||
const { amount, outcome, createdTime, challengeSlug } = bet
|
const { amount, outcome, createdTime, challengeSlug } = bet
|
||||||
|
@ -101,10 +91,10 @@ export function BetStatusText(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('text-sm text-gray-500', className)}>
|
<div className={clsx('text-sm text-gray-500', className)}>
|
||||||
{bettor ? (
|
{!hideUser ? (
|
||||||
<UserLink name={bettor.name} username={bettor.username} />
|
<UserLink name={bet.userName} username={bet.userUsername} />
|
||||||
) : (
|
) : (
|
||||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
<span>{self?.id === bet.userId ? 'You' : 'A trader'}</span>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
{outOfTotalAmount}
|
{outOfTotalAmount}
|
||||||
|
|
|
@ -14,9 +14,7 @@ import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { getProbability } from 'common/calculate'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { Tipper } from '../tipper'
|
import { Tipper } from '../tipper'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
|
@ -301,16 +299,6 @@ export function ContractCommentInput(props: {
|
||||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
|
||||||
<CommentBetArea
|
|
||||||
betsByCurrentUser={props.betsByCurrentUser}
|
|
||||||
contract={props.contract}
|
|
||||||
commentsByCurrentUser={props.commentsByCurrentUser}
|
|
||||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
|
||||||
user={useUser()}
|
|
||||||
className={props.className}
|
|
||||||
mostRecentCommentableBet={mostRecentCommentableBet}
|
|
||||||
/>
|
|
||||||
<CommentInput
|
<CommentInput
|
||||||
replyToUser={props.replyToUser}
|
replyToUser={props.replyToUser}
|
||||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
parentAnswerOutcome={props.parentAnswerOutcome}
|
||||||
|
@ -319,56 +307,6 @@ export function ContractCommentInput(props: {
|
||||||
className={props.className}
|
className={props.className}
|
||||||
presetId={id}
|
presetId={id}
|
||||||
/>
|
/>
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommentBetArea(props: {
|
|
||||||
betsByCurrentUser: Bet[]
|
|
||||||
contract: Contract
|
|
||||||
commentsByCurrentUser: ContractComment[]
|
|
||||||
parentAnswerOutcome?: string
|
|
||||||
user?: User | null
|
|
||||||
className?: string
|
|
||||||
mostRecentCommentableBet?: Bet
|
|
||||||
}) {
|
|
||||||
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
|
|
||||||
|
|
||||||
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
|
||||||
contract,
|
|
||||||
Date.now(),
|
|
||||||
betsByCurrentUser
|
|
||||||
)
|
|
||||||
|
|
||||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
|
||||||
<div className="mb-1 text-gray-500">
|
|
||||||
{mostRecentCommentableBet && (
|
|
||||||
<BetStatusText
|
|
||||||
contract={contract}
|
|
||||||
bet={mostRecentCommentableBet}
|
|
||||||
isSelf={true}
|
|
||||||
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
|
||||||
<>
|
|
||||||
{"You're"}
|
|
||||||
<CommentStatus
|
|
||||||
outcome={outcome}
|
|
||||||
contract={contract}
|
|
||||||
prob={
|
|
||||||
contract.outcomeType === 'BINARY'
|
|
||||||
? getProbability(contract)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,17 @@ 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 } = liquidity
|
const { userId, createdTime, isAnte } = 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
|
||||||
|
@ -24,8 +28,15 @@ 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="flex w-full gap-2 pt-3">
|
<Row className="items-center gap-2 pt-3">
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
||||||
) : bettor ? (
|
) : bettor ? (
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
||||||
import { useContractFollows } from 'web/hooks/use-follows'
|
import { useContractFollows } from 'web/hooks/use-follows'
|
||||||
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export const FollowMarketButton = (props: {
|
||||||
Watch
|
Watch
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<FollowMarketModal
|
<WatchMarketModal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={`You ${
|
title={`You ${
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Image from 'next/future/image'
|
||||||
import { SparklesIcon } from '@heroicons/react/solid'
|
import { SparklesIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
@ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
||||||
<img
|
<Image
|
||||||
height={250}
|
height={250}
|
||||||
width={250}
|
width={250}
|
||||||
className="self-center"
|
className="self-center"
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||||
import { formatMoney, formatPercent } from 'common/util/format'
|
import { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { sortBy } from 'lodash'
|
import { sortBy } from 'lodash'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { cancelBet } from 'web/lib/firebase/api'
|
import { cancelBet } from 'web/lib/firebase/api'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
|
@ -109,16 +109,14 @@ function LimitBet(props: {
|
||||||
setIsCancelling(true)
|
setIsCancelling(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = useUserById(bet.userId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
{!isYou && (
|
{!isYou && (
|
||||||
<td>
|
<td>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
avatarUrl={user?.avatarUrl}
|
avatarUrl={bet.userAvatarUrl}
|
||||||
username={user?.username}
|
username={bet.userUsername}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
27
web/components/market-intro-panel.tsx
Normal file
27
web/components/market-intro-panel.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import Image from 'next/future/image'
|
||||||
|
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
|
|
||||||
|
export function MarketIntroPanel() {
|
||||||
|
return (
|
||||||
|
<Col>
|
||||||
|
<div className="text-xl">Play-money predictions</div>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
height={125}
|
||||||
|
width={125}
|
||||||
|
className="my-4 self-center"
|
||||||
|
src="/welcome/manipurple.png"
|
||||||
|
alt="Manifold Markets gradient logo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 text-sm">
|
||||||
|
Manifold Markets is a play-money prediction market platform where you
|
||||||
|
can forecast anything.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BetSignUpPrompt />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
350
web/components/notification-settings.tsx
Normal file
350
web/components/notification-settings.tsx
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
import React, { memo, ReactNode, useEffect, useState } from 'react'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
notification_subscription_types,
|
||||||
|
notification_destination_types,
|
||||||
|
PrivateUser,
|
||||||
|
} from 'common/user'
|
||||||
|
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import {
|
||||||
|
CashIcon,
|
||||||
|
ChatIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
InboxInIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
LightBulbIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/outline'
|
||||||
|
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { SwitchSetting } from 'web/components/switch-setting'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
import {
|
||||||
|
storageStore,
|
||||||
|
usePersistentState,
|
||||||
|
} from 'web/hooks/use-persistent-state'
|
||||||
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
|
|
||||||
|
export function NotificationSettings(props: {
|
||||||
|
navigateToSection: string | undefined
|
||||||
|
privateUser: PrivateUser
|
||||||
|
}) {
|
||||||
|
const { navigateToSection, privateUser } = props
|
||||||
|
const [showWatchModal, setShowWatchModal] = useState(false)
|
||||||
|
|
||||||
|
const emailsEnabled: Array<keyof notification_subscription_types> = [
|
||||||
|
'all_comments_on_watched_markets',
|
||||||
|
'all_replies_to_my_comments_on_watched_markets',
|
||||||
|
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
|
||||||
|
'all_answers_on_watched_markets',
|
||||||
|
'all_replies_to_my_answers_on_watched_markets',
|
||||||
|
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
|
||||||
|
'your_contract_closed',
|
||||||
|
'all_comments_on_my_markets',
|
||||||
|
'all_answers_on_my_markets',
|
||||||
|
|
||||||
|
'resolutions_on_watched_markets_with_shares_in',
|
||||||
|
'resolutions_on_watched_markets',
|
||||||
|
|
||||||
|
'trending_markets',
|
||||||
|
'onboarding_flow',
|
||||||
|
'thank_you_for_purchases',
|
||||||
|
|
||||||
|
'tagged_user', // missing tagged on contract description email
|
||||||
|
'contract_from_followed_user',
|
||||||
|
'unique_bettors_on_your_contract',
|
||||||
|
// 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
|
||||||
|
// biggest winner, here are the rest of your markets
|
||||||
|
|
||||||
|
// 'referral_bonuses',
|
||||||
|
// 'on_new_follow',
|
||||||
|
// 'tips_on_your_markets',
|
||||||
|
// 'tips_on_your_comments',
|
||||||
|
// maybe the following?
|
||||||
|
// 'probability_updates_on_watched_markets',
|
||||||
|
// 'limit_order_fills',
|
||||||
|
]
|
||||||
|
const browserDisabled: Array<keyof notification_subscription_types> = [
|
||||||
|
'trending_markets',
|
||||||
|
'profit_loss_updates',
|
||||||
|
'onboarding_flow',
|
||||||
|
'thank_you_for_purchases',
|
||||||
|
]
|
||||||
|
|
||||||
|
type SectionData = {
|
||||||
|
label: string
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
[key in keyof Partial<notification_subscription_types>]: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments: SectionData = {
|
||||||
|
label: 'New Comments',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
all_comments_on_watched_markets: 'All new comments',
|
||||||
|
all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
|
||||||
|
// TODO: combine these two
|
||||||
|
all_replies_to_my_comments_on_watched_markets:
|
||||||
|
'Only replies to your comments',
|
||||||
|
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 = {
|
||||||
|
label: 'New Answers',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
all_answers_on_watched_markets: 'All new answers',
|
||||||
|
all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`,
|
||||||
|
// answers_by_followed_users_on_watched_markets: 'By followed users',
|
||||||
|
// answers_by_market_creator_on_watched_markets: 'By market creator',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const updates: SectionData = {
|
||||||
|
label: 'Updates & Resolutions',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
market_updates_on_watched_markets: 'All creator updates',
|
||||||
|
market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`,
|
||||||
|
resolutions_on_watched_markets: 'All market resolutions',
|
||||||
|
resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`,
|
||||||
|
// probability_updates_on_watched_markets: 'Probability updates',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const yourMarkets: SectionData = {
|
||||||
|
label: 'Markets You Created',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
your_contract_closed: 'Your market has closed (and needs resolution)',
|
||||||
|
all_comments_on_my_markets: 'Comments on your markets',
|
||||||
|
all_answers_on_my_markets: 'Answers on your markets',
|
||||||
|
subsidized_your_market: 'Your market was subsidized',
|
||||||
|
tips_on_your_markets: 'Likes on your markets',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const bonuses: SectionData = {
|
||||||
|
label: 'Bonuses',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
betting_streaks: 'Prediction streak bonuses',
|
||||||
|
referral_bonuses: 'Referral bonuses from referring users',
|
||||||
|
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const otherBalances: SectionData = {
|
||||||
|
label: 'Other',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
loan_income: 'Automatic loans from your profitable bets',
|
||||||
|
limit_order_fills: 'Limit order fills',
|
||||||
|
tips_on_your_comments: 'Tips on your comments',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const userInteractions: SectionData = {
|
||||||
|
label: 'Users',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
tagged_user: 'A user tagged you',
|
||||||
|
on_new_follow: 'Someone followed you',
|
||||||
|
contract_from_followed_user: 'New markets created by users you follow',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const generalOther: SectionData = {
|
||||||
|
label: 'Other',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
trending_markets: 'Weekly interesting markets',
|
||||||
|
thank_you_for_purchases: 'Thank you notes for your purchases',
|
||||||
|
onboarding_flow: 'Explanatory emails to help you get started',
|
||||||
|
// profit_loss_updates: 'Weekly profit/loss updates',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationSettingLine(props: {
|
||||||
|
description: string
|
||||||
|
subscriptionTypeKey: keyof notification_subscription_types
|
||||||
|
destinations: notification_destination_types[]
|
||||||
|
}) {
|
||||||
|
const { description, subscriptionTypeKey, destinations } = props
|
||||||
|
const previousInAppValue = destinations.includes('browser')
|
||||||
|
const previousEmailValue = destinations.includes('email')
|
||||||
|
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
|
||||||
|
const loading = 'Changing Notifications Settings'
|
||||||
|
const success = 'Changed Notification Settings!'
|
||||||
|
const highlight = navigateToSection === subscriptionTypeKey
|
||||||
|
|
||||||
|
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
|
||||||
|
toast
|
||||||
|
.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
notificationPreferences: {
|
||||||
|
...privateUser.notificationPreferences,
|
||||||
|
[subscriptionTypeKey]: destinations.includes(setting)
|
||||||
|
? destinations.filter((d) => d !== setting)
|
||||||
|
: uniq([...destinations, setting]),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
success,
|
||||||
|
loading,
|
||||||
|
error: 'Error changing notification settings. Try again?',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
if (setting === 'browser') {
|
||||||
|
setInAppEnabled(newValue)
|
||||||
|
} else {
|
||||||
|
setEmailEnabled(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'my-1 gap-1 text-gray-300',
|
||||||
|
highlight ? 'rounded-md bg-indigo-100 p-1' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Col className="ml-3 gap-2 text-sm">
|
||||||
|
<Row className="gap-2 font-medium text-gray-700">
|
||||||
|
<span>{description}</span>
|
||||||
|
</Row>
|
||||||
|
<Row className={'gap-4'}>
|
||||||
|
{!browserDisabled.includes(subscriptionTypeKey) && (
|
||||||
|
<SwitchSetting
|
||||||
|
checked={inAppEnabled}
|
||||||
|
onChange={(newVal) => changeSetting('browser', newVal)}
|
||||||
|
label={'Web'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{emailsEnabled.includes(subscriptionTypeKey) && (
|
||||||
|
<SwitchSetting
|
||||||
|
checked={emailEnabled}
|
||||||
|
onChange={(newVal) => changeSetting('email', newVal)}
|
||||||
|
label={'Email'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsersSavedPreference = (
|
||||||
|
key: keyof notification_subscription_types
|
||||||
|
) => {
|
||||||
|
return privateUser.notificationPreferences[key] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section = memo(function Section(props: {
|
||||||
|
icon: ReactNode
|
||||||
|
data: SectionData
|
||||||
|
}) {
|
||||||
|
const { icon, data } = props
|
||||||
|
const { label, subscriptionTypeToDescription } = data
|
||||||
|
const expand =
|
||||||
|
navigateToSection &&
|
||||||
|
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
|
||||||
|
key:
|
||||||
|
'NotificationsSettingsSection-' +
|
||||||
|
Object.keys(subscriptionTypeToDescription).join('-'),
|
||||||
|
store: storageStore(safeLocalStorage()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Not working as the default value for expanded, so using a useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
if (expand) setExpanded(true)
|
||||||
|
}, [expand, setExpanded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={clsx('ml-2 gap-2')}>
|
||||||
|
<Row
|
||||||
|
className={'mt-1 cursor-pointer items-center gap-2 text-gray-600'}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUpIcon className="h-5 w-5 text-xs text-gray-500">
|
||||||
|
Hide
|
||||||
|
</ChevronUpIcon>
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-5 w-5 text-xs text-gray-500">
|
||||||
|
Show
|
||||||
|
</ChevronDownIcon>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
||||||
|
{Object.entries(subscriptionTypeToDescription).map(([key, value]) => (
|
||||||
|
<NotificationSettingLine
|
||||||
|
subscriptionTypeKey={key as keyof notification_subscription_types}
|
||||||
|
destinations={getUsersSavedPreference(
|
||||||
|
key as keyof notification_subscription_types
|
||||||
|
)}
|
||||||
|
description={value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'p-2'}>
|
||||||
|
<Col className={'gap-6'}>
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>Notifications for Watched Markets</span>
|
||||||
|
<InformationCircleIcon
|
||||||
|
className="-mb-1 h-5 w-5 cursor-pointer text-gray-500"
|
||||||
|
onClick={() => setShowWatchModal(true)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Section icon={<ChatIcon className={'h-6 w-6'} />} data={comments} />
|
||||||
|
<Section
|
||||||
|
icon={<TrendingUpIcon className={'h-6 w-6'} />}
|
||||||
|
data={updates}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
icon={<LightBulbIcon className={'h-6 w-6'} />}
|
||||||
|
data={answers}
|
||||||
|
/>
|
||||||
|
<Section icon={<UserIcon className={'h-6 w-6'} />} data={yourMarkets} />
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>Balance Changes</span>
|
||||||
|
</Row>
|
||||||
|
<Section
|
||||||
|
icon={<CurrencyDollarIcon className={'h-6 w-6'} />}
|
||||||
|
data={bonuses}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
icon={<CashIcon className={'h-6 w-6'} />}
|
||||||
|
data={otherBalances}
|
||||||
|
/>
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>General</span>
|
||||||
|
</Row>
|
||||||
|
<Section
|
||||||
|
icon={<UsersIcon className={'h-6 w-6'} />}
|
||||||
|
data={userInteractions}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
icon={<InboxInIcon className={'h-6 w-6'} />}
|
||||||
|
data={generalOther}
|
||||||
|
/>
|
||||||
|
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,26 +3,52 @@ import { Col } from 'web/components/layout/col'
|
||||||
import {
|
import {
|
||||||
BETTING_STREAK_BONUS_AMOUNT,
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
BETTING_STREAK_BONUS_MAX,
|
BETTING_STREAK_BONUS_MAX,
|
||||||
|
BETTING_STREAK_RESET_HOUR,
|
||||||
} from 'common/economy'
|
} from 'common/economy'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function BettingStreakModal(props: {
|
export function BettingStreakModal(props: {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
|
currentUser?: User | null
|
||||||
}) {
|
}) {
|
||||||
const { isOpen, setOpen } = props
|
const { isOpen, setOpen, currentUser } = props
|
||||||
|
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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
|
||||||
<span className="text-xl">Daily betting streaks</span>
|
className={clsx(
|
||||||
|
'text-8xl',
|
||||||
|
missingStreak ? 'grayscale' : 'grayscale-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
🔥
|
||||||
|
</span>
|
||||||
|
{missingStreak && (
|
||||||
|
<Col className={' gap-2 text-center'}>
|
||||||
|
<span className={'font-bold'}>
|
||||||
|
You haven't predicted yet today!
|
||||||
|
</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
If the fire icon is gray, this means you haven't predicted yet
|
||||||
|
today to get your streak bonus. Get out there and make a
|
||||||
|
prediction!
|
||||||
|
</span>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<span className="text-xl">Daily prediction streaks</span>
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What are they?</span>
|
<span className={'text-indigo-700'}>• What are they?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
|
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
|
||||||
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
|
of consecutive predicting up to{' '}
|
||||||
. The more days you bet in a row, the more you earn!
|
{formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict
|
||||||
|
in a row, the more you earn!
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-indigo-700'}>
|
<span className={'text-indigo-700'}>
|
||||||
• Where can I check my streak?
|
• Where can I check my streak?
|
||||||
|
@ -36,3 +62,17 @@ export function BettingStreakModal(props: {
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasCompletedStreakToday(user: User) {
|
||||||
|
const now = dayjs().utc()
|
||||||
|
const utcTodayAtResetHour = now
|
||||||
|
.hour(BETTING_STREAK_RESET_HOUR)
|
||||||
|
.minute(0)
|
||||||
|
.second(0)
|
||||||
|
const utcYesterdayAtResetHour = utcTodayAtResetHour.subtract(1, 'day')
|
||||||
|
let resetTime = utcTodayAtResetHour.valueOf()
|
||||||
|
if (now.isBefore(utcTodayAtResetHour)) {
|
||||||
|
resetTime = utcYesterdayAtResetHour.valueOf()
|
||||||
|
}
|
||||||
|
return (user?.lastBetTime ?? 0) > resetTime
|
||||||
|
}
|
||||||
|
|
133
web/components/profile/twitch-panel.tsx
Normal file
133
web/components/profile/twitch-panel.tsx
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
34
web/components/switch-setting.tsx
Normal file
34
web/components/switch-setting.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { Switch } from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const SwitchSetting = (props: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label: string
|
||||||
|
}) => {
|
||||||
|
const { checked, onChange, label } = props
|
||||||
|
return (
|
||||||
|
<Switch.Group as="div" className="flex items-center">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
className={clsx(
|
||||||
|
checked ? 'bg-indigo-600' : 'bg-gray-200',
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
<Switch.Label as="span" className="ml-3">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||||
|
</Switch.Label>
|
||||||
|
</Switch.Group>
|
||||||
|
)
|
||||||
|
}
|
|
@ -28,7 +28,10 @@ import { ReferralsButton } from 'web/components/referrals-button'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
import {
|
||||||
|
BettingStreakModal,
|
||||||
|
hasCompletedStreakToday,
|
||||||
|
} from 'web/components/profile/betting-streak-modal'
|
||||||
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'
|
||||||
|
@ -83,6 +86,7 @@ export function UserPage(props: { user: User }) {
|
||||||
<BettingStreakModal
|
<BettingStreakModal
|
||||||
isOpen={showBettingStreakModal}
|
isOpen={showBettingStreakModal}
|
||||||
setOpen={setShowBettingStreakModal}
|
setOpen={setShowBettingStreakModal}
|
||||||
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
{showLoansModal && (
|
{showLoansModal && (
|
||||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||||
|
@ -139,7 +143,12 @@ export function UserPage(props: { user: User }) {
|
||||||
<span>profit</span>
|
<span>profit</span>
|
||||||
</Col>
|
</Col>
|
||||||
<Col
|
<Col
|
||||||
className={'cursor-pointer items-center text-gray-500'}
|
className={clsx(
|
||||||
|
'cursor-pointer items-center text-gray-500',
|
||||||
|
isCurrentUser && !hasCompletedStreakToday(user)
|
||||||
|
? 'grayscale'
|
||||||
|
: 'grayscale-0'
|
||||||
|
)}
|
||||||
onClick={() => setShowBettingStreakModal(true)}
|
onClick={() => setShowBettingStreakModal(true)}
|
||||||
>
|
>
|
||||||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { Notification } from 'common/notification'
|
import { Notification } from 'common/notification'
|
||||||
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map, partition } from 'lodash'
|
import { groupBy, map, partition } from 'lodash'
|
||||||
|
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
|
||||||
if (!result.data) return undefined
|
if (!result.data) return undefined
|
||||||
const notifications = result.data as Notification[]
|
const notifications = result.data as Notification[]
|
||||||
|
|
||||||
return getAppropriateNotifications(
|
return notifications.filter((n) => !n.isSeenOnHref)
|
||||||
notifications,
|
}, [result.data])
|
||||||
privateUser.notificationPreferences
|
|
||||||
).filter((n) => !n.isSeenOnHref)
|
|
||||||
}, [privateUser.notificationPreferences, result.data])
|
|
||||||
|
|
||||||
return notifications
|
return notifications
|
||||||
}
|
}
|
||||||
|
@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
})
|
})
|
||||||
return notificationGroups
|
return notificationGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessPriorityReasons = [
|
|
||||||
'on_contract_with_users_comment',
|
|
||||||
'on_contract_with_users_answer',
|
|
||||||
// Notifications not currently generated for users who've sold their shares
|
|
||||||
'on_contract_with_users_shares_out',
|
|
||||||
// Not sure if users will want to see these w/ less:
|
|
||||||
// 'on_contract_with_users_shares_in',
|
|
||||||
]
|
|
||||||
|
|
||||||
function getAppropriateNotifications(
|
|
||||||
notifications: Notification[],
|
|
||||||
notificationPreferences?: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (notificationPreferences === 'all') return notifications
|
|
||||||
if (notificationPreferences === 'less')
|
|
||||||
return notifications.filter(
|
|
||||||
(n) =>
|
|
||||||
n.reason &&
|
|
||||||
// Show all contract notifications and any that aren't in the above list:
|
|
||||||
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
|
||||||
)
|
|
||||||
if (notificationPreferences === 'none') return []
|
|
||||||
|
|
||||||
return notifications
|
|
||||||
}
|
|
||||||
|
|
9
web/lib/api/api-key.ts
Normal file
9
web/lib/api/api-key.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { updatePrivateUser } from '../firebase/users'
|
||||||
|
|
||||||
|
export const generateNewApiKey = async (userId: string) => {
|
||||||
|
const newApiKey = crypto.randomUUID()
|
||||||
|
|
||||||
|
return await updatePrivateUser(userId, { apiKey: newApiKey })
|
||||||
|
.then(() => newApiKey)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
|
@ -25,7 +25,6 @@ import { Contract } from 'common/contract'
|
||||||
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
|
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
|
||||||
import { db } from 'web/lib/firebase/init'
|
import { db } from 'web/lib/firebase/init'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { getUser } from 'web/lib/firebase/users'
|
|
||||||
|
|
||||||
export const groups = coll<Group>('groups')
|
export const groups = coll<Group>('groups')
|
||||||
export const groupMembers = (groupId: string) =>
|
export const groupMembers = (groupId: string) =>
|
||||||
|
@ -254,9 +253,9 @@ export function getGroupLinkToDisplay(contract: Contract) {
|
||||||
return groupToDisplay
|
return groupToDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMembers(group: Group) {
|
export async function listMemberIds(group: Group) {
|
||||||
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
return await Promise.all(members.map((m) => m.userId).map(getUser))
|
return members.map((m) => m.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const topFollowedGroupsQuery = query(
|
export const topFollowedGroupsQuery = query(
|
||||||
|
|
20
web/lib/icons/bold-icon.tsx
Normal file
20
web/lib/icons/bold-icon.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
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"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||||
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
21
web/lib/icons/italic-icon.tsx
Normal file
21
web/lib/icons/italic-icon.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
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"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<line x1="19" y1="4" x2="10" y2="4"></line>
|
||||||
|
<line x1="14" y1="20" x2="5" y2="20"></line>
|
||||||
|
<line x1="15" y1="4" x2="9" y2="20"></line>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
20
web/lib/icons/link-icon.tsx
Normal file
20
web/lib/icons/link-icon.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
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"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
41
web/lib/twitch/link-twitch-account.ts
Normal file
41
web/lib/twitch/link-twitch-account.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { PrivateUser, User } from 'common/user'
|
||||||
|
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
|
||||||
|
|
||||||
|
export async function initLinkTwitchAccount(
|
||||||
|
manifoldUserID: string,
|
||||||
|
manifoldUserAPIKey: string
|
||||||
|
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
|
||||||
|
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
manifoldID: manifoldUserID,
|
||||||
|
apiKey: manifoldUserAPIKey,
|
||||||
|
redirectURL: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const responseData = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(responseData.message)
|
||||||
|
}
|
||||||
|
const responseFetch = fetch(
|
||||||
|
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
|
||||||
|
)
|
||||||
|
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkTwitchAccountRedirect(
|
||||||
|
user: User,
|
||||||
|
privateUser: PrivateUser
|
||||||
|
) {
|
||||||
|
const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id))
|
||||||
|
if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key")
|
||||||
|
|
||||||
|
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
|
||||||
|
|
||||||
|
window.location.href = twitchAuthURL
|
||||||
|
}
|
23
web/pages/api/v0/twitch/save.ts
Normal file
23
web/pages/api/v0/twitch/save.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import {
|
||||||
|
CORS_ORIGIN_MANIFOLD,
|
||||||
|
CORS_ORIGIN_LOCALHOST,
|
||||||
|
} from 'common/envs/constants'
|
||||||
|
import { applyCorsHeaders } from 'web/lib/api/cors'
|
||||||
|
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
|
||||||
|
|
||||||
|
export const config = { api: { bodyParser: true } }
|
||||||
|
|
||||||
|
export default async function route(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
await applyCorsHeaders(req, res, {
|
||||||
|
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||||
|
methods: 'POST',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const backendRes = await fetchBackend(req, 'savetwitchcredentials')
|
||||||
|
await forwardResponse(res, backendRes)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error talking to cloud function: ', err)
|
||||||
|
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||||
|
}
|
||||||
|
}
|
|
@ -426,7 +426,7 @@ export function NewContract(props: {
|
||||||
<div className="form-control mb-1 items-start">
|
<div className="form-control mb-1 items-start">
|
||||||
<label className="label mb-1 gap-2">
|
<label className="label mb-1 gap-2">
|
||||||
<span>Question closes in</span>
|
<span>Question closes in</span>
|
||||||
<InfoTooltip text="Betting will be halted after this time (local timezone)." />
|
<InfoTooltip text="Predicting will be halted after this time (local timezone)." />
|
||||||
</label>
|
</label>
|
||||||
<Row className={'w-full items-center gap-2'}>
|
<Row className={'w-full items-center gap-2'}>
|
||||||
<ChoicesToggleGroup
|
<ChoicesToggleGroup
|
||||||
|
@ -483,7 +483,7 @@ export function NewContract(props: {
|
||||||
<label className="label mb-1 gap-2">
|
<label className="label mb-1 gap-2">
|
||||||
<span>Cost</span>
|
<span>Cost</span>
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
text={`Cost to create your question. This amount is used to subsidize predictions.`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{!deservesFreeMarket ? (
|
{!deservesFreeMarket ? (
|
||||||
|
|
|
@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteLink href="/experimental/home">
|
<SiteLink href="/experimental/home">
|
||||||
<Button size="lg" color="blue" className={clsx(className, 'flex whitespace-nowrap')}>
|
<Button
|
||||||
|
size="lg"
|
||||||
|
color="blue"
|
||||||
|
className={clsx(className, 'flex whitespace-nowrap')}
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
|
|
@ -1,28 +1,28 @@
|
||||||
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 { debounce, sortBy, take } from 'lodash'
|
|
||||||
import { SearchIcon } from '@heroicons/react/outline'
|
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } 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 { Page } from 'web/components/page'
|
||||||
import { listAllBets } from 'web/lib/firebase/bets'
|
|
||||||
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
getGroupBySlug,
|
getGroupBySlug,
|
||||||
groupPath,
|
groupPath,
|
||||||
joinGroup,
|
joinGroup,
|
||||||
listMembers,
|
listMemberIds,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
|
import {
|
||||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
useGroup,
|
||||||
|
useGroupContractIds,
|
||||||
|
useMemberIds,
|
||||||
|
} from 'web/hooks/use-group'
|
||||||
import { Leaderboard } from 'web/components/leaderboard'
|
import { Leaderboard } from 'web/components/leaderboard'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { EditGroupButton } from 'web/components/groups/edit-group-button'
|
import { EditGroupButton } from 'web/components/groups/edit-group-button'
|
||||||
|
@ -31,13 +31,9 @@ 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 { Tabs } from 'web/components/layout/tabs'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
||||||
import { Modal } from 'web/components/layout/modal'
|
|
||||||
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 { FollowList } from 'web/components/follow-list'
|
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { searchInAny } from 'common/util/parse'
|
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
|
@ -53,13 +49,14 @@ 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 { SelectMarketsModal } from 'web/components/contract-select-modal'
|
||||||
|
|
||||||
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[] } }) {
|
||||||
const { slugs } = props.params
|
const { slugs } = props.params
|
||||||
|
|
||||||
const group = await getGroupBySlug(slugs[0])
|
const group = await getGroupBySlug(slugs[0])
|
||||||
const members = group && (await listMembers(group))
|
const memberIds = group && (await listMemberIds(group))
|
||||||
const creatorPromise = group ? getUser(group.creatorId) : null
|
const creatorPromise = group ? getUser(group.creatorId) : null
|
||||||
|
|
||||||
const contracts =
|
const contracts =
|
||||||
|
@ -71,33 +68,24 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
: 'open'
|
: 'open'
|
||||||
const aboutPost =
|
const aboutPost =
|
||||||
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
|
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
|
||||||
const bets = await Promise.all(
|
|
||||||
contracts.map((contract: Contract) => listAllBets(contract.id))
|
|
||||||
)
|
|
||||||
const messages = group && (await listAllCommentsOnGroup(group.id))
|
const messages = group && (await listAllCommentsOnGroup(group.id))
|
||||||
|
|
||||||
const creatorScores = scoreCreators(contracts)
|
const cachedTopTraderIds =
|
||||||
const traderScores = scoreTraders(contracts, bets)
|
(group && group.cachedLeaderboard?.topTraders) ?? []
|
||||||
const [topCreators, topTraders] =
|
const cachedTopCreatorIds =
|
||||||
(members && [
|
(group && group.cachedLeaderboard?.topCreators) ?? []
|
||||||
toTopUsers(creatorScores, members),
|
const topTraders = await toTopUsers(cachedTopTraderIds)
|
||||||
toTopUsers(traderScores, members),
|
|
||||||
]) ??
|
const topCreators = await toTopUsers(cachedTopCreatorIds)
|
||||||
[]
|
|
||||||
|
|
||||||
const creator = await creatorPromise
|
const creator = await creatorPromise
|
||||||
// Only count unresolved markets
|
|
||||||
const contractsCount = contracts.filter((c) => !c.isResolved).length
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
contractsCount,
|
|
||||||
group,
|
group,
|
||||||
members,
|
memberIds,
|
||||||
creator,
|
creator,
|
||||||
traderScores,
|
|
||||||
topTraders,
|
topTraders,
|
||||||
creatorScores,
|
|
||||||
topCreators,
|
topCreators,
|
||||||
messages,
|
messages,
|
||||||
aboutPost,
|
aboutPost,
|
||||||
|
@ -107,19 +95,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
revalidate: 60, // regenerate after a minute
|
revalidate: 60, // regenerate after a minute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTopUsers(userScores: { [userId: string]: number }, users: User[]) {
|
|
||||||
const topUserPairs = take(
|
|
||||||
sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
|
|
||||||
10
|
|
||||||
).filter(([_, score]) => score >= 0.5)
|
|
||||||
|
|
||||||
const topUsers = topUserPairs.map(
|
|
||||||
([userId]) => users.filter((user) => user.id === userId)[0]
|
|
||||||
)
|
|
||||||
return topUsers.filter((user) => user)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
return { paths: [], fallback: 'blocking' }
|
return { paths: [], fallback: 'blocking' }
|
||||||
}
|
}
|
||||||
|
@ -132,39 +107,25 @@ const groupSubpages = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function GroupPage(props: {
|
export default function GroupPage(props: {
|
||||||
contractsCount: number
|
|
||||||
group: Group | null
|
group: Group | null
|
||||||
members: User[]
|
memberIds: string[]
|
||||||
creator: User
|
creator: User
|
||||||
traderScores: { [userId: string]: number }
|
topTraders: { user: User; score: number }[]
|
||||||
topTraders: User[]
|
topCreators: { user: User; score: number }[]
|
||||||
creatorScores: { [userId: string]: number }
|
|
||||||
topCreators: User[]
|
|
||||||
messages: GroupComment[]
|
messages: GroupComment[]
|
||||||
aboutPost: Post
|
aboutPost: Post
|
||||||
suggestedFilter: 'open' | 'all'
|
suggestedFilter: 'open' | 'all'
|
||||||
}) {
|
}) {
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
contractsCount: 0,
|
|
||||||
group: null,
|
group: null,
|
||||||
members: [],
|
memberIds: [],
|
||||||
creator: null,
|
creator: null,
|
||||||
traderScores: {},
|
|
||||||
topTraders: [],
|
topTraders: [],
|
||||||
creatorScores: {},
|
|
||||||
topCreators: [],
|
topCreators: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
suggestedFilter: 'open',
|
suggestedFilter: 'open',
|
||||||
}
|
}
|
||||||
const {
|
const { creator, topTraders, topCreators, suggestedFilter } = props
|
||||||
contractsCount,
|
|
||||||
creator,
|
|
||||||
traderScores,
|
|
||||||
topTraders,
|
|
||||||
creatorScores,
|
|
||||||
topCreators,
|
|
||||||
suggestedFilter,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { slugs } = router.query as { slugs: string[] }
|
const { slugs } = router.query as { slugs: string[] }
|
||||||
|
@ -175,7 +136,7 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
const members = useMembers(group?.id) ?? props.members
|
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrerUsername: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
|
@ -186,18 +147,25 @@ export default function GroupPage(props: {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
const isCreator = user && group && user.id === group.creatorId
|
const isCreator = user && group && user.id === group.creatorId
|
||||||
const isMember = user && members.map((m) => m.id).includes(user.id)
|
const isMember = user && memberIds.includes(user.id)
|
||||||
|
const maxLeaderboardSize = 50
|
||||||
|
|
||||||
const leaderboard = (
|
const leaderboard = (
|
||||||
<Col>
|
<Col>
|
||||||
<GroupLeaderboards
|
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||||
traderScores={traderScores}
|
<GroupLeaderboard
|
||||||
creatorScores={creatorScores}
|
topUsers={topTraders}
|
||||||
topTraders={topTraders}
|
title="🏅 Top traders"
|
||||||
topCreators={topCreators}
|
header="Profit"
|
||||||
members={members}
|
maxToShow={maxLeaderboardSize}
|
||||||
user={user}
|
|
||||||
/>
|
/>
|
||||||
|
<GroupLeaderboard
|
||||||
|
topUsers={topCreators}
|
||||||
|
title="🏅 Top creators"
|
||||||
|
header="Market volume"
|
||||||
|
maxToShow={maxLeaderboardSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -216,7 +184,7 @@ export default function GroupPage(props: {
|
||||||
creator={creator}
|
creator={creator}
|
||||||
isCreator={!!isCreator}
|
isCreator={!!isCreator}
|
||||||
user={user}
|
user={user}
|
||||||
members={members}
|
memberIds={memberIds}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
@ -233,7 +201,6 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
badge: `${contractsCount}`,
|
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: questionsTab,
|
content: questionsTab,
|
||||||
href: groupPath(group.slug, 'markets'),
|
href: groupPath(group.slug, 'markets'),
|
||||||
|
@ -312,9 +279,9 @@ function GroupOverview(props: {
|
||||||
creator: User
|
creator: User
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
isCreator: boolean
|
isCreator: boolean
|
||||||
members: User[]
|
memberIds: string[]
|
||||||
}) {
|
}) {
|
||||||
const { group, creator, isCreator, user, members } = props
|
const { group, creator, isCreator, user, memberIds } = props
|
||||||
const anyoneCanJoinChoices: { [key: string]: string } = {
|
const anyoneCanJoinChoices: { [key: string]: string } = {
|
||||||
Closed: 'false',
|
Closed: 'false',
|
||||||
Open: 'true',
|
Open: 'true',
|
||||||
|
@ -333,7 +300,7 @@ function GroupOverview(props: {
|
||||||
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
|
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
|
||||||
group.slug
|
group.slug
|
||||||
)}${postFix}`
|
)}${postFix}`
|
||||||
const isMember = user ? members.map((m) => m.id).includes(user.id) : false
|
const isMember = user ? memberIds.includes(user.id) : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -399,179 +366,46 @@ function GroupOverview(props: {
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className={'mt-2'}>
|
|
||||||
<div className="mb-2 text-lg">Members</div>
|
|
||||||
<GroupMemberSearch members={members} group={group} />
|
|
||||||
</Col>
|
|
||||||
</Col>
|
</Col>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchBar(props: { setQuery: (query: string) => void }) {
|
function GroupLeaderboard(props: {
|
||||||
const { setQuery } = props
|
topUsers: { user: User; score: number }[]
|
||||||
const debouncedQuery = debounce(setQuery, 50)
|
|
||||||
return (
|
|
||||||
<div className={'relative'}>
|
|
||||||
<SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
onChange={(e) => debouncedQuery(e.target.value)}
|
|
||||||
placeholder="Find a member"
|
|
||||||
className="input input-bordered mb-4 w-full pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupMemberSearch(props: { members: User[]; group: Group }) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const { group } = props
|
|
||||||
let { members } = props
|
|
||||||
|
|
||||||
// Use static members on load, but also listen to member changes:
|
|
||||||
const listenToMembers = useMembers(group.id)
|
|
||||||
if (listenToMembers) {
|
|
||||||
members = listenToMembers
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO use find-active-contracts to sort by?
|
|
||||||
const matches = sortBy(members, [(member) => member.name]).filter((m) =>
|
|
||||||
searchInAny(query, m.name, m.username)
|
|
||||||
)
|
|
||||||
const matchLimit = 25
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SearchBar setQuery={setQuery} />
|
|
||||||
<Col className={'gap-2'}>
|
|
||||||
{matches.length > 0 && (
|
|
||||||
<FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} />
|
|
||||||
)}
|
|
||||||
{matches.length > 25 && (
|
|
||||||
<div className={'text-center'}>
|
|
||||||
And {matches.length - matchLimit} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortedLeaderboard(props: {
|
|
||||||
users: User[]
|
|
||||||
scoreFunction: (user: User) => number
|
|
||||||
title: string
|
title: string
|
||||||
|
maxToShow: number
|
||||||
header: string
|
header: string
|
||||||
maxToShow?: number
|
|
||||||
}) {
|
}) {
|
||||||
const { users, scoreFunction, title, header, maxToShow } = props
|
const { topUsers, title, maxToShow, header } = props
|
||||||
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
|
|
||||||
|
const scoresByUser = topUsers.reduce((acc, { user, score }) => {
|
||||||
|
acc[user.id] = score
|
||||||
|
return acc
|
||||||
|
}, {} as { [key: string]: number })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
className="max-w-xl"
|
className="max-w-xl"
|
||||||
users={sortedUsers}
|
users={topUsers.map((t) => t.user)}
|
||||||
title={title}
|
title={title}
|
||||||
columns={[
|
columns={[
|
||||||
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
|
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },
|
||||||
]}
|
]}
|
||||||
maxToShow={maxToShow}
|
maxToShow={maxToShow}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupLeaderboards(props: {
|
|
||||||
traderScores: { [userId: string]: number }
|
|
||||||
creatorScores: { [userId: string]: number }
|
|
||||||
topTraders: User[]
|
|
||||||
topCreators: User[]
|
|
||||||
members: User[]
|
|
||||||
user: User | null | undefined
|
|
||||||
}) {
|
|
||||||
const { traderScores, creatorScores, members, topTraders, topCreators } =
|
|
||||||
props
|
|
||||||
const maxToShow = 50
|
|
||||||
// Consider hiding M$0
|
|
||||||
// If it's just one member (curator), show all bettors, otherwise just show members
|
|
||||||
return (
|
|
||||||
<Col>
|
|
||||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
|
||||||
{members.length > 1 ? (
|
|
||||||
<>
|
|
||||||
<SortedLeaderboard
|
|
||||||
users={members}
|
|
||||||
scoreFunction={(user) => traderScores[user.id] ?? 0}
|
|
||||||
title="🏅 Top traders"
|
|
||||||
header="Profit"
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
<SortedLeaderboard
|
|
||||||
users={members}
|
|
||||||
scoreFunction={(user) => creatorScores[user.id] ?? 0}
|
|
||||||
title="🏅 Top creators"
|
|
||||||
header="Market volume"
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Leaderboard
|
|
||||||
className="max-w-xl"
|
|
||||||
title="🏅 Top traders"
|
|
||||||
users={topTraders}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Profit',
|
|
||||||
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
<Leaderboard
|
|
||||||
className="max-w-xl"
|
|
||||||
title="🏅 Top creators"
|
|
||||||
users={topCreators}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Market volume',
|
|
||||||
renderCell: (user) =>
|
|
||||||
formatMoney(creatorScores[user.id] ?? 0),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddContractButton(props: { group: Group; user: User }) {
|
function AddContractButton(props: { group: Group; user: User }) {
|
||||||
const { group, user } = props
|
const { group, user } = props
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [contracts, setContracts] = useState<Contract[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const groupContractIds = useGroupContractIds(group.id)
|
const groupContractIds = useGroupContractIds(group.id)
|
||||||
|
|
||||||
async function addContractToCurrentGroup(contract: Contract) {
|
async function onSubmit(contracts: Contract[]) {
|
||||||
if (contracts.map((c) => c.id).includes(contract.id)) {
|
await Promise.all(
|
||||||
setContracts(contracts.filter((c) => c.id !== contract.id))
|
contracts.map((contract) => addContractToGroup(group, contract, user.id))
|
||||||
} else setContracts([...contracts, contract])
|
)
|
||||||
}
|
|
||||||
|
|
||||||
async function doneAddingContracts() {
|
|
||||||
Promise.all(
|
|
||||||
contracts.map(async (contract) => {
|
|
||||||
setLoading(true)
|
|
||||||
await addContractToGroup(group, contract, user.id)
|
|
||||||
})
|
|
||||||
).then(() => {
|
|
||||||
setLoading(false)
|
|
||||||
setOpen(false)
|
|
||||||
setContracts([])
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -587,18 +421,11 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<SelectMarketsModal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
className={'max-w-4xl sm:p-0'}
|
title="Add markets"
|
||||||
size={'xl'}
|
description={
|
||||||
>
|
|
||||||
<Col
|
|
||||||
className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'}
|
|
||||||
>
|
|
||||||
<Col className="p-8 pb-0">
|
|
||||||
<div className={'text-xl text-indigo-700'}>Add markets</div>
|
|
||||||
|
|
||||||
<div className={'text-md my-4 text-gray-600'}>
|
<div className={'text-md my-4 text-gray-600'}>
|
||||||
Add pre-existing markets to this group, or{' '}
|
Add pre-existing markets to this group, or{' '}
|
||||||
<Link href={`/create?groupId=${group.id}`}>
|
<Link href={`/create?groupId=${group.id}`}>
|
||||||
|
@ -608,50 +435,13 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{contracts.length > 0 && (
|
submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`}
|
||||||
<Col className={'w-full '}>
|
onSubmit={onSubmit}
|
||||||
{!loading ? (
|
contractSearchOptions={{
|
||||||
<Row className={'justify-end gap-4'}>
|
additionalFilter: { excludeContractIds: groupContractIds },
|
||||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
|
||||||
Add {contracts.length} question
|
|
||||||
{contracts.length > 1 && 's'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setContracts([])
|
|
||||||
}}
|
|
||||||
color={'gray'}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
) : (
|
|
||||||
<Row className={'justify-center'}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<div className={'overflow-y-scroll sm:px-8'}>
|
|
||||||
<ContractSearch
|
|
||||||
user={user}
|
|
||||||
hideOrderSelector={true}
|
|
||||||
onContractClick={addContractToCurrentGroup}
|
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
|
||||||
additionalFilter={{
|
|
||||||
excludeContractIds: groupContractIds,
|
|
||||||
}}
|
|
||||||
highlightOptions={{
|
|
||||||
contractIds: contracts.map((c) => c.id),
|
|
||||||
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -684,3 +474,15 @@ function JoinGroupButton(props: {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toTopUsers = async (
|
||||||
|
cachedUserIds: { userId: string; score: number }[]
|
||||||
|
): Promise<{ user: User; score: number }[]> =>
|
||||||
|
(
|
||||||
|
await Promise.all(
|
||||||
|
cachedUserIds.map(async (e) => {
|
||||||
|
const user = await getUser(e.userId)
|
||||||
|
return { user, score: e.score ?? 0 }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter((e) => e.user != null)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Tabs } 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 from 'next/router'
|
import Router, { useRouter } from 'next/router'
|
||||||
import { Notification, notification_source_types } from 'common/notification'
|
import { 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'
|
||||||
|
@ -26,6 +26,7 @@ import {
|
||||||
import {
|
import {
|
||||||
NotificationGroup,
|
NotificationGroup,
|
||||||
useGroupedNotifications,
|
useGroupedNotifications,
|
||||||
|
useUnseenGroupedNotification,
|
||||||
} from 'web/hooks/use-notifications'
|
} from 'web/hooks/use-notifications'
|
||||||
import { TrendingUpIcon } from '@heroicons/react/outline'
|
import { TrendingUpIcon } from '@heroicons/react/outline'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -40,7 +41,7 @@ import { Pagination } from 'web/components/pagination'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { NotificationSettings } from 'web/components/NotificationSettings'
|
import { NotificationSettings } from 'web/components/notification-settings'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
@ -56,24 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const privateUser = usePrivateUser()
|
const privateUser = usePrivateUser()
|
||||||
|
const router = useRouter()
|
||||||
|
const [navigateToSection, setNavigateToSection] = useState<string>()
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (privateUser === null) Router.push('/')
|
if (privateUser === null) Router.push('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = { ...router.query }
|
||||||
|
if (query.tab === 'settings') {
|
||||||
|
setActiveIndex(1)
|
||||||
|
}
|
||||||
|
if (query.section) {
|
||||||
|
setNavigateToSection(query.section as string)
|
||||||
|
}
|
||||||
|
}, [router.query])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
||||||
<Title text={'Notifications'} className={'hidden md:block'} />
|
<Title text={'Notifications'} className={'hidden md:block'} />
|
||||||
<SEO title="Notifications" description="Manifold user notifications" />
|
<SEO title="Notifications" description="Manifold user notifications" />
|
||||||
|
|
||||||
{privateUser && (
|
{privateUser && router.isReady && (
|
||||||
<div>
|
<div>
|
||||||
<Tabs
|
<ControlledTabs
|
||||||
currentPageForAnalytics={'notifications'}
|
currentPageForAnalytics={'notifications'}
|
||||||
labelClassName={'pb-2 pt-1 '}
|
labelClassName={'pb-2 pt-1 '}
|
||||||
className={'mb-0 sm:mb-2'}
|
className={'mb-0 sm:mb-2'}
|
||||||
defaultIndex={0}
|
activeIndex={activeIndex}
|
||||||
|
onClick={(title, i) => {
|
||||||
|
router.replace(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...router.query,
|
||||||
|
tab: title.toLowerCase(),
|
||||||
|
section: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ shallow: true }
|
||||||
|
)
|
||||||
|
setActiveIndex(i)
|
||||||
|
}}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
|
@ -82,9 +110,10 @@ export default function Notifications() {
|
||||||
{
|
{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
content: (
|
content: (
|
||||||
<div className={''}>
|
<NotificationSettings
|
||||||
<NotificationSettings />
|
navigateToSection={navigateToSection}
|
||||||
</div>
|
privateUser={privateUser}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
@ -128,16 +157,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
|
||||||
const { privateUser } = props
|
const { privateUser } = props
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const allGroupedNotifications = useGroupedNotifications(privateUser)
|
const allGroupedNotifications = useGroupedNotifications(privateUser)
|
||||||
|
const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser)
|
||||||
const paginatedGroupedNotifications = useMemo(() => {
|
const paginatedGroupedNotifications = useMemo(() => {
|
||||||
if (!allGroupedNotifications) return
|
if (!allGroupedNotifications) return
|
||||||
const start = page * NOTIFICATIONS_PER_PAGE
|
const start = page * NOTIFICATIONS_PER_PAGE
|
||||||
const end = start + NOTIFICATIONS_PER_PAGE
|
const end = start + NOTIFICATIONS_PER_PAGE
|
||||||
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
|
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
|
||||||
const remainingNotification = allGroupedNotifications.slice(end)
|
|
||||||
for (const notification of remainingNotification) {
|
|
||||||
if (notification.isSeen) break
|
|
||||||
else setNotificationsAsSeen(notification.notifications)
|
|
||||||
}
|
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
local?.setItem(
|
local?.setItem(
|
||||||
'notification-groups',
|
'notification-groups',
|
||||||
|
@ -146,6 +172,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
|
||||||
return maxNotificationsToShow
|
return maxNotificationsToShow
|
||||||
}, [allGroupedNotifications, page])
|
}, [allGroupedNotifications, page])
|
||||||
|
|
||||||
|
// Set all notifications that don't fit on the first page to seen
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
paginatedGroupedNotifications &&
|
||||||
|
paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE
|
||||||
|
) {
|
||||||
|
const allUnseenNotifications = unseenGroupedNotifications
|
||||||
|
?.map((ng) => ng.notifications)
|
||||||
|
.flat()
|
||||||
|
allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications)
|
||||||
|
}
|
||||||
|
}, [paginatedGroupedNotifications, unseenGroupedNotifications])
|
||||||
|
|
||||||
if (!paginatedGroupedNotifications || !allGroupedNotifications)
|
if (!paginatedGroupedNotifications || !allGroupedNotifications)
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
|
|
||||||
|
@ -259,7 +298,7 @@ function IncomeNotificationGroupItem(props: {
|
||||||
...notificationsForSourceTitle[0],
|
...notificationsForSourceTitle[0],
|
||||||
sourceText: sum.toString(),
|
sourceText: sum.toString(),
|
||||||
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
|
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
|
||||||
data: JSON.stringify(uniqueUsers),
|
data: { uniqueUsers },
|
||||||
}
|
}
|
||||||
newNotifications.push(newNotification)
|
newNotifications.push(newNotification)
|
||||||
}
|
}
|
||||||
|
@ -376,7 +415,7 @@ function IncomeNotificationItem(props: {
|
||||||
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
|
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
|
||||||
const isUniqueBettorBonus = sourceType === 'bonus'
|
const isUniqueBettorBonus = sourceType === 'bonus'
|
||||||
const userLinks: MultiUserLinkInfo[] =
|
const userLinks: MultiUserLinkInfo[] =
|
||||||
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : []
|
isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
|
@ -390,7 +429,7 @@ function IncomeNotificationItem(props: {
|
||||||
reasonText = !simple
|
reasonText = !simple
|
||||||
? `Bonus for ${
|
? `Bonus for ${
|
||||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||||
} new traders on`
|
} new predictors on`
|
||||||
: 'bonus on'
|
: 'bonus on'
|
||||||
} else if (sourceType === 'tip') {
|
} else if (sourceType === 'tip') {
|
||||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||||
|
@ -398,19 +437,22 @@ function IncomeNotificationItem(props: {
|
||||||
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
|
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
|
||||||
else reasonText = 'for your'
|
else reasonText = 'for your'
|
||||||
} else if (sourceType === 'loan' && sourceText) {
|
} else if (sourceType === 'loan' && sourceText) {
|
||||||
reasonText = `of your invested bets returned as a`
|
reasonText = `of your invested predictions returned as a`
|
||||||
// TODO: support just 'like' notification without a tip
|
// TODO: support just 'like' notification without a tip
|
||||||
} else if (sourceType === 'tip_and_like' && sourceText) {
|
} else if (sourceType === 'tip_and_like' && sourceText) {
|
||||||
reasonText = !simple ? `liked` : `in likes on`
|
reasonText = !simple ? `liked` : `in likes on`
|
||||||
}
|
}
|
||||||
|
|
||||||
const streakInDays =
|
const streakInDays = notification.data?.streak
|
||||||
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
? notification.data?.streak
|
||||||
|
: Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
||||||
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
||||||
: user?.currentBettingStreak ?? 0
|
: user?.currentBettingStreak ?? 0
|
||||||
const bettingStreakText =
|
const bettingStreakText =
|
||||||
sourceType === 'betting_streak_bonus' &&
|
sourceType === 'betting_streak_bonus' &&
|
||||||
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
|
(sourceText
|
||||||
|
? `🔥 ${streakInDays} day Prediction Streak`
|
||||||
|
: 'Prediction Streak')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -508,7 +550,7 @@ function IncomeNotificationItem(props: {
|
||||||
{(isTip || isUniqueBettorBonus) && (
|
{(isTip || isUniqueBettorBonus) && (
|
||||||
<MultiUserTransactionLink
|
<MultiUserTransactionLink
|
||||||
userInfos={userLinks}
|
userInfos={userLinks}
|
||||||
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
|
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Row className={'line-clamp-2 flex max-w-xl'}>
|
<Row className={'line-clamp-2 flex max-w-xl'}>
|
||||||
|
@ -992,18 +1034,20 @@ function getReasonForShowingNotification(
|
||||||
) {
|
) {
|
||||||
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
||||||
let reasonText: string
|
let reasonText: string
|
||||||
|
// TODO: we could leave out this switch and just use the reason field now that they have more information
|
||||||
|
if (reason === 'tagged_user')
|
||||||
|
reasonText = justSummary ? 'tagged you' : 'tagged you on'
|
||||||
|
else
|
||||||
switch (sourceType) {
|
switch (sourceType) {
|
||||||
case 'comment':
|
case 'comment':
|
||||||
if (reason === 'reply_to_users_answer')
|
if (reason === 'reply_to_users_answer')
|
||||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||||
else if (reason === 'tagged_user')
|
|
||||||
reasonText = justSummary ? 'tagged you' : 'tagged you on'
|
|
||||||
else if (reason === 'reply_to_users_comment')
|
else if (reason === 'reply_to_users_comment')
|
||||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||||
else reasonText = justSummary ? `commented` : `commented on`
|
else reasonText = justSummary ? `commented` : `commented on`
|
||||||
break
|
break
|
||||||
case 'contract':
|
case 'contract':
|
||||||
if (reason === 'you_follow_user')
|
if (reason === 'contract_from_followed_user')
|
||||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||||
else if (sourceUpdateType === 'resolved')
|
else if (sourceUpdateType === 'resolved')
|
||||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||||
|
@ -1011,7 +1055,8 @@ function getReasonForShowingNotification(
|
||||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||||
break
|
break
|
||||||
case 'answer':
|
case 'answer':
|
||||||
if (reason === 'on_users_contract') reasonText = `answered your question `
|
if (reason === 'answer_on_your_contract')
|
||||||
|
reasonText = `answered your question `
|
||||||
else reasonText = `answered`
|
else reasonText = `answered`
|
||||||
break
|
break
|
||||||
case 'follow':
|
case 'follow':
|
||||||
|
|
|
@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
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 { User, PrivateUser } from 'common/user'
|
||||||
import {
|
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||||
getUserAndPrivateUser,
|
|
||||||
updateUser,
|
|
||||||
updatePrivateUser,
|
|
||||||
} from 'web/lib/firebase/users'
|
|
||||||
import { defaultBannerUrl } from 'web/components/user-page'
|
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 Textarea from 'react-expanding-textarea'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
|
import { generateNewApiKey } from 'web/lib/api/api-key'
|
||||||
|
import { TwitchPanel } from 'web/components/profile/twitch-panel'
|
||||||
|
|
||||||
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) } }
|
||||||
|
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateApiKey = async (e: React.MouseEvent) => {
|
const updateApiKey = async (e: React.MouseEvent) => {
|
||||||
const newApiKey = crypto.randomUUID()
|
const newApiKey = await generateNewApiKey(user.id)
|
||||||
setApiKey(newApiKey)
|
setApiKey(newApiKey ?? '')
|
||||||
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
|
|
||||||
setApiKey(privateUser.apiKey || '')
|
|
||||||
})
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TwitchPanel />
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
||||||
<Page>
|
<Page>
|
||||||
<SEO
|
<SEO
|
||||||
title="Tournaments"
|
title="Tournaments"
|
||||||
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
description="Win money by predicting in forecasting tournaments on current events, sports, science, and more"
|
||||||
/>
|
/>
|
||||||
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
|
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
|
||||||
{sections.map(
|
{sections.map(
|
||||||
|
|
120
web/pages/twitch.tsx
Normal file
120
web/pages/twitch.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { 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'
|
||||||
|
|
||||||
|
export default function TwitchLandingPage() {
|
||||||
|
useSaveReferral()
|
||||||
|
useTracking('view twitch landing page')
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
|
const privateUser = usePrivateUser()
|
||||||
|
const twitchUser = privateUser?.twitchInfo?.twitchName
|
||||||
|
|
||||||
|
const callback =
|
||||||
|
user && privateUser
|
||||||
|
? () => linkTwitchAccountRedirect(user, privateUser)
|
||||||
|
: async () => {
|
||||||
|
const result = await firebaseLogin()
|
||||||
|
|
||||||
|
const userId = result.user.uid
|
||||||
|
const { user, privateUser } = await getUserAndPrivateUser(userId)
|
||||||
|
if (!user || !privateUser) return
|
||||||
|
|
||||||
|
await linkTwitchAccountRedirect(user, privateUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const getStarted = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const promise = callback()
|
||||||
|
track('twitch page button click')
|
||||||
|
await promise
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error('Failed to sign up. Please try again later.')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<SEO
|
||||||
|
title="Manifold Markets on Twitch"
|
||||||
|
description="Get more out of Twitch with play-money betting markets."
|
||||||
|
/>
|
||||||
|
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
||||||
|
<ManifoldLogo />
|
||||||
|
</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} />
|
||||||
|
|
||||||
|
{twitchUser ? (
|
||||||
|
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
|
||||||
|
<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>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
BIN
web/public/twitch-logo.png
Normal file
BIN
web/public/twitch-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
Loading…
Reference in New Issue
Block a user