Merge branch 'main' into group-sidebar
This commit is contained in:
commit
ad44fc7b11
|
@ -1,10 +1,9 @@
|
|||
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
||||
import { CPMMContract } from './contract'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { User } from './user'
|
||||
|
||||
export const getNewLiquidityProvision = (
|
||||
user: User,
|
||||
userId: string,
|
||||
amount: number,
|
||||
contract: CPMMContract,
|
||||
newLiquidityProvisionId: string
|
||||
|
@ -18,7 +17,7 @@ export const getNewLiquidityProvision = (
|
|||
|
||||
const newLiquidityProvision: LiquidityProvision = {
|
||||
id: newLiquidityProvisionId,
|
||||
userId: user.id,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
pool: newPool,
|
||||
|
|
|
@ -15,6 +15,12 @@ import { Answer } from './answer'
|
|||
|
||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @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(
|
||||
providerId: string,
|
||||
|
@ -51,7 +57,7 @@ export function getAnteBets(
|
|||
|
||||
const { createdTime } = contract
|
||||
|
||||
const yesBet: Bet = {
|
||||
const yesBet: NormalizedBet = {
|
||||
id: yesAnteId,
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
|
@ -65,7 +71,7 @@ export function getAnteBets(
|
|||
fees: noFees,
|
||||
}
|
||||
|
||||
const noBet: Bet = {
|
||||
const noBet: NormalizedBet = {
|
||||
id: noAnteId,
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
|
@ -93,7 +99,7 @@ export function getFreeAnswerAnte(
|
|||
|
||||
const { createdTime } = contract
|
||||
|
||||
const anteBet: Bet = {
|
||||
const anteBet: NormalizedBet = {
|
||||
id: anteBetId,
|
||||
userId: anteBettorId,
|
||||
contractId: contract.id,
|
||||
|
@ -123,7 +129,7 @@ export function getMultipleChoiceAntes(
|
|||
|
||||
const { createdTime } = contract
|
||||
|
||||
const bets: Bet[] = answers.map((answer, i) => ({
|
||||
const bets: NormalizedBet[] = answers.map((answer, i) => ({
|
||||
id: betDocIds[i],
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
|
@ -173,7 +179,7 @@ export function getNumericAnte(
|
|||
range(0, bucketCount).map((_, i) => [i, betAnte])
|
||||
)
|
||||
|
||||
const anteBet: NumericBet = {
|
||||
const anteBet: NormalizedBet<NumericBet> = {
|
||||
id: newBetId,
|
||||
userId: anteBettorId,
|
||||
contractId: contract.id,
|
||||
|
|
|
@ -3,6 +3,12 @@ import { Fees } from './fees'
|
|||
export type Bet = {
|
||||
id: string
|
||||
userId: string
|
||||
|
||||
// denormalized for bet lists
|
||||
userAvatarUrl?: string
|
||||
userUsername: string
|
||||
userName: string
|
||||
|
||||
contractId: string
|
||||
createdTime: number
|
||||
|
||||
|
|
|
@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
|||
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||
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
|
||||
|
|
|
@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) {
|
|||
}
|
||||
|
||||
// TODO: Before open sourcing, we should turn these into env vars
|
||||
export function isAdmin(email: string) {
|
||||
export function isAdmin(email?: string) {
|
||||
if (!email) {
|
||||
return false
|
||||
}
|
||||
return ENV_CONFIG.adminEmails.includes(email)
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,9 @@ export type EnvConfig = {
|
|||
|
||||
// Branding
|
||||
moneyMoniker: string // e.g. 'M$'
|
||||
bettor?: string // e.g. 'bettor' or 'predictor'
|
||||
presentBet?: string // e.g. 'bet' or 'predict'
|
||||
pastBet?: string // e.g. 'bet' or 'prediction'
|
||||
faviconPath?: string // Should be a file in /public
|
||||
navbarLogoPath?: string
|
||||
newQuestionPlaceholders: string[]
|
||||
|
@ -74,10 +77,14 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
'iansphilips@gmail.com', // Ian
|
||||
'd4vidchee@gmail.com', // D4vid
|
||||
'federicoruizcassarino@gmail.com', // Fede
|
||||
'ingawei@gmail.com', //Inga
|
||||
],
|
||||
visibility: 'PUBLIC',
|
||||
|
||||
moneyMoniker: 'M$',
|
||||
bettor: 'predictor',
|
||||
pastBet: 'prediction',
|
||||
presentBet: 'predict',
|
||||
navbarLogoPath: '',
|
||||
faviconPath: '/favicon.ico',
|
||||
newQuestionPlaceholders: [
|
||||
|
|
|
@ -31,7 +31,10 @@ import {
|
|||
floatingLesserEqual,
|
||||
} 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 = {
|
||||
newBet: CandidateBet
|
||||
newPool?: { [outcome: string]: number }
|
||||
|
@ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
export const getNewMultiBetInfo = (
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { notification_subscription_types, PrivateUser } from './user'
|
||||
import { DOMAIN } from './envs/constants'
|
||||
import { notification_preference } from './user-notification-preferences'
|
||||
|
||||
export type Notification = {
|
||||
id: string
|
||||
|
@ -18,7 +17,7 @@ export type Notification = {
|
|||
sourceUserUsername?: string
|
||||
sourceUserAvatarUrl?: string
|
||||
sourceText?: string
|
||||
data?: string
|
||||
data?: { [key: string]: any }
|
||||
|
||||
sourceContractTitle?: string
|
||||
sourceContractCreatorUsername?: string
|
||||
|
@ -29,6 +28,7 @@ export type Notification = {
|
|||
|
||||
isSeenOnHref?: string
|
||||
}
|
||||
|
||||
export type notification_source_types =
|
||||
| 'contract'
|
||||
| 'comment'
|
||||
|
@ -54,7 +54,7 @@ export type notification_source_update_types =
|
|||
| 'deleted'
|
||||
| 'closed'
|
||||
|
||||
/* Optional - if possible use a keyof notification_subscription_types */
|
||||
/* Optional - if possible use a notification_preference */
|
||||
export type notification_reason_types =
|
||||
| 'tagged_user'
|
||||
| 'on_new_follow'
|
||||
|
@ -92,68 +92,152 @@ export type notification_reason_types =
|
|||
| '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 type BettingStreakData = {
|
||||
streak: number
|
||||
bonusAmount: number
|
||||
}
|
||||
|
||||
export const getDestinationsForUser = async (
|
||||
privateUser: PrivateUser,
|
||||
reason: notification_reason_types | keyof notification_subscription_types
|
||||
) => {
|
||||
const notificationSettings = privateUser.notificationSubscriptionTypes
|
||||
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]
|
||||
: []
|
||||
}
|
||||
return {
|
||||
sendToEmail: destinations.includes('email'),
|
||||
sendToBrowser: destinations.includes('browser'),
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
type notification_descriptions = {
|
||||
[key in notification_preference]: {
|
||||
simple: string
|
||||
detailed: string
|
||||
}
|
||||
}
|
||||
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||
all_answers_on_my_markets: {
|
||||
simple: 'Answers on your markets',
|
||||
detailed: 'Answers on your own markets',
|
||||
},
|
||||
all_comments_on_my_markets: {
|
||||
simple: 'Comments on your markets',
|
||||
detailed: 'Comments on your own markets',
|
||||
},
|
||||
answers_by_followed_users_on_watched_markets: {
|
||||
simple: 'Only answers by users you follow',
|
||||
detailed: "Only answers by users you follow on markets you're watching",
|
||||
},
|
||||
answers_by_market_creator_on_watched_markets: {
|
||||
simple: 'Only answers by market creator',
|
||||
detailed: "Only answers by market creator on markets you're watching",
|
||||
},
|
||||
betting_streaks: {
|
||||
simple: 'For predictions made over consecutive days',
|
||||
detailed: 'Bonuses for predictions made over consecutive days',
|
||||
},
|
||||
comments_by_followed_users_on_watched_markets: {
|
||||
simple: 'Only comments by users you follow',
|
||||
detailed:
|
||||
'Only comments by users that you follow on markets that you watch',
|
||||
},
|
||||
contract_from_followed_user: {
|
||||
simple: 'New markets from users you follow',
|
||||
detailed: 'New markets from users you follow',
|
||||
},
|
||||
limit_order_fills: {
|
||||
simple: 'Limit order fills',
|
||||
detailed: 'When your limit order is filled by another user',
|
||||
},
|
||||
loan_income: {
|
||||
simple: 'Automatic loans from your predictions in unresolved markets',
|
||||
detailed:
|
||||
'Automatic loans from your predictions that are locked in unresolved markets',
|
||||
},
|
||||
market_updates_on_watched_markets: {
|
||||
simple: 'All creator updates',
|
||||
detailed: 'All market updates made by the creator',
|
||||
},
|
||||
market_updates_on_watched_markets_with_shares_in: {
|
||||
simple: "Only creator updates on markets that you're invested in",
|
||||
detailed:
|
||||
"Only updates made by the creator on markets that you're invested in",
|
||||
},
|
||||
on_new_follow: {
|
||||
simple: 'A user followed you',
|
||||
detailed: 'A user followed you',
|
||||
},
|
||||
onboarding_flow: {
|
||||
simple: 'Emails to help you get started using Manifold',
|
||||
detailed: 'Emails to help you learn how to use Manifold',
|
||||
},
|
||||
probability_updates_on_watched_markets: {
|
||||
simple: 'Large changes in probability on markets that you watch',
|
||||
detailed: 'Large changes in probability on markets that you watch',
|
||||
},
|
||||
profit_loss_updates: {
|
||||
simple: 'Weekly profit and loss updates',
|
||||
detailed: 'Weekly profit and loss updates',
|
||||
},
|
||||
referral_bonuses: {
|
||||
simple: 'For referring new users',
|
||||
detailed: 'Bonuses you receive from referring a new user',
|
||||
},
|
||||
resolutions_on_watched_markets: {
|
||||
simple: 'All market resolutions',
|
||||
detailed: "All resolutions on markets that you're watching",
|
||||
},
|
||||
resolutions_on_watched_markets_with_shares_in: {
|
||||
simple: "Only market resolutions that you're invested in",
|
||||
detailed:
|
||||
"Only resolutions of markets you're watching and that you're invested in",
|
||||
},
|
||||
subsidized_your_market: {
|
||||
simple: 'Your market was subsidized',
|
||||
detailed: 'When someone subsidizes your market',
|
||||
},
|
||||
tagged_user: {
|
||||
simple: 'A user tagged you',
|
||||
detailed: 'When another use tags you',
|
||||
},
|
||||
thank_you_for_purchases: {
|
||||
simple: 'Thank you notes for your purchases',
|
||||
detailed: 'Thank you notes for your purchases',
|
||||
},
|
||||
tipped_comments_on_watched_markets: {
|
||||
simple: 'Only highly tipped comments on markets that you watch',
|
||||
detailed: 'Only highly tipped comments on markets that you watch',
|
||||
},
|
||||
tips_on_your_comments: {
|
||||
simple: 'Tips on your comments',
|
||||
detailed: 'Tips on your comments',
|
||||
},
|
||||
tips_on_your_markets: {
|
||||
simple: 'Tips/Likes on your markets',
|
||||
detailed: 'Tips/Likes on your markets',
|
||||
},
|
||||
trending_markets: {
|
||||
simple: 'Weekly interesting markets',
|
||||
detailed: 'Weekly interesting markets',
|
||||
},
|
||||
unique_bettors_on_your_contract: {
|
||||
simple: 'For unique predictors on your markets',
|
||||
detailed: 'Bonuses for unique predictors on your markets',
|
||||
},
|
||||
your_contract_closed: {
|
||||
simple: 'Your market has closed and you need to resolve it',
|
||||
detailed: 'Your market has closed and you need to resolve it',
|
||||
},
|
||||
all_comments_on_watched_markets: {
|
||||
simple: 'All new comments',
|
||||
detailed: 'All new comments on markets you follow',
|
||||
},
|
||||
all_comments_on_contracts_with_shares_in_on_watched_markets: {
|
||||
simple: `Only on markets you're invested in`,
|
||||
detailed: `Comments on markets that you're watching and you're invested in`,
|
||||
},
|
||||
all_replies_to_my_comments_on_watched_markets: {
|
||||
simple: 'Only replies to your comments',
|
||||
detailed: "Only replies to your comments on markets you're watching",
|
||||
},
|
||||
all_replies_to_my_answers_on_watched_markets: {
|
||||
simple: 'Only replies to your answers',
|
||||
detailed: "Only replies to your answers on markets you're watching",
|
||||
},
|
||||
all_answers_on_watched_markets: {
|
||||
simple: 'All new answers',
|
||||
detailed: "All new answers on markets you're watching",
|
||||
},
|
||||
all_answers_on_contracts_with_shares_in_on_watched_markets: {
|
||||
simple: `Only on markets you're invested in`,
|
||||
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract'
|
|||
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
|
||||
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) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
|
||||
type AnyTxnType =
|
||||
| Donation
|
||||
| Tip
|
||||
| Manalink
|
||||
| Referral
|
||||
| UniqueBettorBonus
|
||||
| BettingStreakBonus
|
||||
| CancelUniqueBettorBonus
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -23,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
| 'REFERRAL'
|
||||
| 'UNIQUE_BETTOR_BONUS'
|
||||
| 'BETTING_STREAK_BONUS'
|
||||
| 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
@ -60,13 +68,42 @@ type Referral = {
|
|||
category: 'REFERRAL'
|
||||
}
|
||||
|
||||
type Bonus = {
|
||||
type UniqueBettorBonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
// This data was mistakenly stored as a stringified JSON object in description previously
|
||||
data: {
|
||||
contractId: string
|
||||
uniqueNewBettorId?: string
|
||||
// Previously stored all unique bettor ids in description
|
||||
uniqueBettorIds?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type BettingStreakBonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'BETTING_STREAK_BONUS'
|
||||
// This data was mistakenly stored as a stringified JSON object in description previously
|
||||
data: {
|
||||
currentBettingStreak?: number
|
||||
}
|
||||
}
|
||||
|
||||
type CancelUniqueBettorBonus = {
|
||||
fromType: 'USER'
|
||||
toType: 'BANK'
|
||||
category: 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||
data: {
|
||||
contractId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
export type ReferralTxn = Txn & Referral
|
||||
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
|
||||
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
|
||||
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
|
||||
|
|
243
common/user-notification-preferences.ts
Normal file
243
common/user-notification-preferences.ts
Normal file
|
@ -0,0 +1,243 @@
|
|||
import { filterDefined } from './util/array'
|
||||
import { notification_reason_types } from './notification'
|
||||
import { getFunctionUrl } from './api'
|
||||
import { DOMAIN } from './envs/constants'
|
||||
import { PrivateUser } from './user'
|
||||
|
||||
export type notification_destination_types = 'email' | 'browser'
|
||||
export type notification_preference = keyof notification_preferences
|
||||
export type notification_preferences = {
|
||||
// Watched Markets
|
||||
all_comments_on_watched_markets: notification_destination_types[]
|
||||
all_answers_on_watched_markets: notification_destination_types[]
|
||||
|
||||
// Comments
|
||||
tipped_comments_on_watched_markets: notification_destination_types[]
|
||||
comments_by_followed_users_on_watched_markets: notification_destination_types[]
|
||||
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
|
||||
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
|
||||
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
||||
|
||||
// Answers
|
||||
answers_by_followed_users_on_watched_markets: notification_destination_types[]
|
||||
answers_by_market_creator_on_watched_markets: notification_destination_types[]
|
||||
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
|
||||
|
||||
// On users' markets
|
||||
your_contract_closed: notification_destination_types[]
|
||||
all_comments_on_my_markets: notification_destination_types[]
|
||||
all_answers_on_my_markets: notification_destination_types[]
|
||||
subsidized_your_market: notification_destination_types[]
|
||||
|
||||
// Market updates
|
||||
resolutions_on_watched_markets: notification_destination_types[]
|
||||
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
|
||||
market_updates_on_watched_markets: notification_destination_types[]
|
||||
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
|
||||
probability_updates_on_watched_markets: notification_destination_types[]
|
||||
|
||||
// Balance Changes
|
||||
loan_income: notification_destination_types[]
|
||||
betting_streaks: notification_destination_types[]
|
||||
referral_bonuses: notification_destination_types[]
|
||||
unique_bettors_on_your_contract: notification_destination_types[]
|
||||
tips_on_your_comments: notification_destination_types[]
|
||||
tips_on_your_markets: notification_destination_types[]
|
||||
limit_order_fills: notification_destination_types[]
|
||||
|
||||
// General
|
||||
tagged_user: notification_destination_types[]
|
||||
on_new_follow: notification_destination_types[]
|
||||
contract_from_followed_user: notification_destination_types[]
|
||||
trending_markets: notification_destination_types[]
|
||||
profit_loss_updates: notification_destination_types[]
|
||||
onboarding_flow: notification_destination_types[]
|
||||
thank_you_for_purchases: notification_destination_types[]
|
||||
}
|
||||
|
||||
export const getDefaultNotificationPreferences = (
|
||||
userId: string,
|
||||
privateUser?: PrivateUser,
|
||||
noEmails?: boolean
|
||||
) => {
|
||||
const {
|
||||
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_preferences
|
||||
}
|
||||
|
||||
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||
// You might want to add a key:value here if there will be multiple notification reasons that map to the same
|
||||
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
|
||||
// 'all_comments_on_watched_markets' subscription type
|
||||
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
|
||||
const notificationReasonToSubscriptionType: Partial<
|
||||
Record<notification_reason_types, notification_preference>
|
||||
> = {
|
||||
you_referred_user: 'referral_bonuses',
|
||||
user_joined_to_bet_on_your_market: 'referral_bonuses',
|
||||
tip_received: 'tips_on_your_comments',
|
||||
bet_fill: 'limit_order_fills',
|
||||
user_joined_from_your_group_invite: 'referral_bonuses',
|
||||
challenge_accepted: 'limit_order_fills',
|
||||
betting_streak_incremented: 'betting_streaks',
|
||||
liked_and_tipped_your_contract: 'tips_on_your_markets',
|
||||
comment_on_your_contract: 'all_comments_on_my_markets',
|
||||
answer_on_your_contract: 'all_answers_on_my_markets',
|
||||
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
|
||||
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
|
||||
update_on_contract_you_follow: 'market_updates_on_watched_markets',
|
||||
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
|
||||
comment_on_contract_with_users_shares_in:
|
||||
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||
answer_on_contract_with_users_shares_in:
|
||||
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||
update_on_contract_with_users_shares_in:
|
||||
'market_updates_on_watched_markets_with_shares_in',
|
||||
resolution_on_contract_with_users_shares_in:
|
||||
'resolutions_on_watched_markets_with_shares_in',
|
||||
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
|
||||
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
|
||||
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
|
||||
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
|
||||
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
|
||||
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
|
||||
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
|
||||
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
|
||||
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
|
||||
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
|
||||
}
|
||||
|
||||
export const getNotificationDestinationsForUser = (
|
||||
privateUser: PrivateUser,
|
||||
reason: notification_reason_types | notification_preference
|
||||
) => {
|
||||
const notificationSettings = privateUser.notificationPreferences
|
||||
let destinations
|
||||
let subscriptionType: notification_preference | undefined
|
||||
if (Object.keys(notificationSettings).includes(reason)) {
|
||||
subscriptionType = reason as notification_preference
|
||||
destinations = notificationSettings[subscriptionType]
|
||||
} else {
|
||||
const key = reason as notification_reason_types
|
||||
subscriptionType = notificationReasonToSubscriptionType[key]
|
||||
destinations = subscriptionType
|
||||
? notificationSettings[subscriptionType]
|
||||
: []
|
||||
}
|
||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||
return {
|
||||
sendToEmail: destinations.includes('email'),
|
||||
sendToBrowser: destinations.includes('browser'),
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
}
|
||||
}
|
205
common/user.ts
205
common/user.ts
|
@ -1,4 +1,5 @@
|
|||
import { filterDefined } from './util/array'
|
||||
import { notification_preferences } from './user-notification-preferences'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
|
@ -65,62 +66,14 @@ export type PrivateUser = {
|
|||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
/** @deprecated - use notificationSubscriptionTypes */
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
notificationSubscriptionTypes: notification_subscription_types
|
||||
notificationPreferences: notification_preferences
|
||||
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 PortfolioMetrics = {
|
||||
investmentValue: number
|
||||
balance: number
|
||||
|
@ -132,139 +85,9 @@ export type PortfolioMetrics = {
|
|||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||
|
||||
export const getDefaultNotificationSettings = (
|
||||
userId: string,
|
||||
privateUser?: PrivateUser,
|
||||
noEmails?: boolean
|
||||
) => {
|
||||
const prevPref = privateUser?.notificationPreferences ?? 'all'
|
||||
const wantsLess = prevPref === 'less'
|
||||
const wantsAll = prevPref === 'all'
|
||||
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(
|
||||
wantsAll,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_answers_on_watched_markets: constructPref(
|
||||
wantsAll,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
|
||||
// Comments
|
||||
tips_on_your_comments: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
comments_by_followed_users_on_watched_markets: constructPref(
|
||||
wantsAll,
|
||||
false
|
||||
),
|
||||
all_replies_to_my_comments_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_replies_to_my_answers_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||
wantsAll,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
|
||||
// Answers
|
||||
answers_by_followed_users_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
answers_by_market_creator_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||
wantsAll,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
|
||||
// On users' markets
|
||||
your_contract_closed: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromResolutionEmails
|
||||
), // High priority
|
||||
all_comments_on_my_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_answers_on_my_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
subsidized_your_market: constructPref(wantsAll || wantsLess, true),
|
||||
|
||||
// Market updates
|
||||
resolutions_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromResolutionEmails
|
||||
),
|
||||
market_updates_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
false
|
||||
),
|
||||
market_updates_on_watched_markets_with_shares_in: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
false
|
||||
),
|
||||
resolutions_on_watched_markets_with_shares_in: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromResolutionEmails
|
||||
),
|
||||
|
||||
//Balance Changes
|
||||
loan_income: constructPref(wantsAll || wantsLess, false),
|
||||
betting_streaks: constructPref(wantsAll || wantsLess, false),
|
||||
referral_bonuses: constructPref(wantsAll || wantsLess, true),
|
||||
unique_bettors_on_your_contract: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
false
|
||||
),
|
||||
tipped_comments_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
tips_on_your_markets: constructPref(wantsAll || wantsLess, true),
|
||||
limit_order_fills: constructPref(wantsAll || wantsLess, false),
|
||||
|
||||
// General
|
||||
tagged_user: constructPref(wantsAll || wantsLess, true),
|
||||
on_new_follow: constructPref(wantsAll || wantsLess, true),
|
||||
contract_from_followed_user: constructPref(wantsAll || wantsLess, true),
|
||||
trending_markets: constructPref(
|
||||
false,
|
||||
!unsubscribedFromWeeklyTrendingEmails
|
||||
),
|
||||
profit_loss_updates: constructPref(false, true),
|
||||
probability_updates_on_watched_markets: constructPref(
|
||||
wantsAll || wantsLess,
|
||||
false
|
||||
),
|
||||
thank_you_for_purchases: constructPref(
|
||||
false,
|
||||
!unsubscribedFromGenericEmails
|
||||
),
|
||||
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
|
||||
} as notification_subscription_types
|
||||
}
|
||||
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
|
||||
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
|
||||
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
|
||||
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
|
||||
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
|
||||
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
|
||||
|
|
|
@ -16,6 +16,10 @@ export function formatMoneyWithDecimals(amount: number) {
|
|||
return ENV_CONFIG.moneyMoniker + amount.toFixed(2)
|
||||
}
|
||||
|
||||
export function capitalFirst(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
export function formatWithCommas(amount: number) {
|
||||
return formatter.format(Math.floor(amount)).replace('$', '')
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ service cloud.firestore {
|
|||
'manticmarkets@gmail.com',
|
||||
'iansphilips@gmail.com',
|
||||
'd4vidchee@gmail.com',
|
||||
'federicoruizcassarino@gmail.com'
|
||||
'federicoruizcassarino@gmail.com',
|
||||
'ingawei@gmail.com'
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -77,7 +78,7 @@ service cloud.firestore {
|
|||
allow read: if userId == request.auth.uid || isAdmin();
|
||||
allow update: if (userId == request.auth.uid || isAdmin())
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
|
||||
}
|
||||
|
||||
match /private-users/{userId}/views/{viewId} {
|
||||
|
@ -161,7 +162,7 @@ service cloud.firestore {
|
|||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['isSeen', 'viewTime']);
|
||||
}
|
||||
|
||||
|
||||
match /{somePath=**}/groupMembers/{memberId} {
|
||||
allow read;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
||||
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({
|
||||
contractId: z.string(),
|
||||
|
@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||
getNewLiquidityProvision(
|
||||
user,
|
||||
user.id,
|
||||
amount,
|
||||
contract,
|
||||
newLiquidityProvisionDoc.id
|
||||
|
@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
|
||||
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 { getUser } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
|
@ -68,10 +69,21 @@ export const changeUser = async (
|
|||
.get()
|
||||
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()
|
||||
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
|
||||
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
|
||||
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
|
||||
betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate))
|
||||
await bulkWriter.flush()
|
||||
console.log('Done writing!')
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import {
|
||||
getDestinationsForUser,
|
||||
BettingStreakData,
|
||||
Notification,
|
||||
notification_reason_types,
|
||||
} from '../../common/notification'
|
||||
|
@ -8,7 +8,7 @@ import { User } from '../../common/user'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { groupBy, uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { getContractBetMetrics } from '../../common/calculate'
|
||||
|
@ -22,7 +22,11 @@ import {
|
|||
sendMarketResolutionEmail,
|
||||
sendNewAnswerEmail,
|
||||
sendNewCommentEmail,
|
||||
sendNewFollowedMarketEmail,
|
||||
sendNewUniqueBettorsEmail,
|
||||
} from './emails'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type recipients_to_reason_texts = {
|
||||
|
@ -62,7 +66,7 @@ export const createNotification = async (
|
|||
const { reason } = userToReasonTexts[userId]
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) continue
|
||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
@ -103,51 +107,14 @@ export const createNotification = async (
|
|||
privateUser,
|
||||
sourceContract
|
||||
)
|
||||
} else if (reason === 'tagged_user') {
|
||||
// TODO: send email to tagged user in new contract
|
||||
} else if (reason === 'subsidized_your_market') {
|
||||
// TODO: send email to creator of market that was subsidized
|
||||
} else if (reason === 'contract_from_followed_user') {
|
||||
// TODO: send email to follower of user who created market
|
||||
} else if (reason === 'on_new_follow') {
|
||||
// TODO: send email to user who was followed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: recipients_to_reason_texts
|
||||
) => {
|
||||
const followers = await firestore
|
||||
.collectionGroup('follows')
|
||||
.where('userId', '==', sourceUser.id)
|
||||
.get()
|
||||
|
||||
followers.docs.forEach((doc) => {
|
||||
const followerUserId = doc.ref.parent.parent?.id
|
||||
if (
|
||||
followerUserId &&
|
||||
shouldReceiveNotification(followerUserId, userToReasonTexts)
|
||||
) {
|
||||
userToReasonTexts[followerUserId] = {
|
||||
reason: 'contract_from_followed_user',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: recipients_to_reason_texts,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
if (id && shouldReceiveNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
const userToReasonTexts: recipients_to_reason_texts = {}
|
||||
|
||||
|
@ -157,15 +124,6 @@ export const createNotification = async (
|
|||
reason: 'on_new_follow',
|
||||
}
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
if (sourceContract.visibility === 'public')
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
await notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
|
@ -278,57 +236,62 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
return
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
||||
// Browser notifications
|
||||
if (sendToBrowser && !browserRecipientIdsList.includes(userId)) {
|
||||
await createBrowserNotification(userId, reason)
|
||||
browserRecipientIdsList.push(userId)
|
||||
}
|
||||
if (sendToEmail && !emailRecipientIdsList.includes(userId)) {
|
||||
if (sourceType === 'comment') {
|
||||
const { repliedToType, repliedToAnswerText, repliedToId, bet } =
|
||||
repliedUsersInfo?.[userId] ?? {}
|
||||
// TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment
|
||||
await sendNewCommentEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
sourceUser,
|
||||
sourceContract,
|
||||
sourceText,
|
||||
sourceId,
|
||||
bet,
|
||||
repliedToAnswerText,
|
||||
repliedToType === 'answer' ? repliedToId : undefined
|
||||
)
|
||||
} else if (sourceType === 'answer')
|
||||
await sendNewAnswerEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
sourceUser.name,
|
||||
sourceText,
|
||||
sourceContract,
|
||||
sourceUser.avatarUrl
|
||||
)
|
||||
else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'resolved' &&
|
||||
resolutionData
|
||||
|
||||
// Emails notifications
|
||||
if (!sendToEmail || emailRecipientIdsList.includes(userId)) return
|
||||
if (sourceType === 'comment') {
|
||||
const { repliedToType, repliedToAnswerText, repliedToId, bet } =
|
||||
repliedUsersInfo?.[userId] ?? {}
|
||||
// TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment
|
||||
await sendNewCommentEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
sourceUser,
|
||||
sourceContract,
|
||||
sourceText,
|
||||
sourceId,
|
||||
bet,
|
||||
repliedToAnswerText,
|
||||
repliedToType === 'answer' ? repliedToId : undefined
|
||||
)
|
||||
emailRecipientIdsList.push(userId)
|
||||
} else if (sourceType === 'answer') {
|
||||
await sendNewAnswerEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
sourceUser.name,
|
||||
sourceText,
|
||||
sourceContract,
|
||||
sourceUser.avatarUrl
|
||||
)
|
||||
emailRecipientIdsList.push(userId)
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'resolved' &&
|
||||
resolutionData
|
||||
) {
|
||||
await sendMarketResolutionEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
resolutionData.userInvestments[userId] ?? 0,
|
||||
resolutionData.userPayouts[userId] ?? 0,
|
||||
sourceUser,
|
||||
resolutionData.creatorPayout,
|
||||
sourceContract,
|
||||
resolutionData.outcome,
|
||||
resolutionData.resolutionProbability,
|
||||
resolutionData.resolutions
|
||||
)
|
||||
await sendMarketResolutionEmail(
|
||||
reason,
|
||||
privateUser,
|
||||
resolutionData.userInvestments[userId] ?? 0,
|
||||
resolutionData.userPayouts[userId] ?? 0,
|
||||
sourceUser,
|
||||
resolutionData.creatorPayout,
|
||||
sourceContract,
|
||||
resolutionData.outcome,
|
||||
resolutionData.resolutionProbability,
|
||||
resolutionData.resolutions
|
||||
)
|
||||
emailRecipientIdsList.push(userId)
|
||||
}
|
||||
}
|
||||
|
@ -505,7 +468,7 @@ export const createTipNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'tip_received'
|
||||
)
|
||||
|
@ -550,7 +513,7 @@ export const createBetFillNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'bet_fill'
|
||||
)
|
||||
|
@ -595,7 +558,7 @@ export const createReferralNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'you_referred_user'
|
||||
)
|
||||
|
@ -649,7 +612,7 @@ export const createLoanIncomeNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'loan_income'
|
||||
)
|
||||
|
@ -687,7 +650,7 @@ export const createChallengeAcceptedNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(challengeCreator.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'challenge_accepted'
|
||||
)
|
||||
|
@ -724,11 +687,12 @@ export const createBettingStreakBonusNotification = async (
|
|||
bet: Bet,
|
||||
contract: Contract,
|
||||
amount: number,
|
||||
streak: number,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'betting_streak_incremented'
|
||||
)
|
||||
|
@ -757,6 +721,10 @@ export const createBettingStreakBonusNotification = async (
|
|||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
data: {
|
||||
streak: streak,
|
||||
bonusAmount: amount,
|
||||
} as BettingStreakData,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
@ -771,7 +739,7 @@ export const createLikeNotification = async (
|
|||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'liked_and_tipped_your_contract'
|
||||
)
|
||||
|
@ -813,42 +781,158 @@ export const createUniqueBettorBonusNotification = async (
|
|||
txnId: string,
|
||||
contract: Contract,
|
||||
amount: number,
|
||||
uniqueBettorIds: string[],
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
console.log('createUniqueBettorBonusNotification')
|
||||
const privateUser = await getPrivateUser(contractCreatorId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = await getDestinationsForUser(
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'unique_bettors_on_your_contract'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${contractCreatorId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: contractCreatorId,
|
||||
reason: 'unique_bettors_on_your_contract',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: txnId,
|
||||
sourceType: 'bonus',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: bettor.name,
|
||||
sourceUserUsername: bettor.username,
|
||||
sourceUserAvatarUrl: bettor.avatarUrl,
|
||||
sourceText: amount.toString(),
|
||||
sourceSlug: contract.slug,
|
||||
sourceTitle: contract.question,
|
||||
// Perhaps not necessary, but just in case
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
if (sendToBrowser) {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${contractCreatorId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: contractCreatorId,
|
||||
reason: 'unique_bettors_on_your_contract',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: txnId,
|
||||
sourceType: 'bonus',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: bettor.name,
|
||||
sourceUserUsername: bettor.username,
|
||||
sourceUserAvatarUrl: bettor.avatarUrl,
|
||||
sourceText: amount.toString(),
|
||||
sourceSlug: contract.slug,
|
||||
sourceTitle: contract.question,
|
||||
// Perhaps not necessary, but just in case
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// TODO send email notification
|
||||
if (!sendToEmail) return
|
||||
const uniqueBettorsExcludingCreator = uniqueBettorIds.filter(
|
||||
(id) => id !== contractCreatorId
|
||||
)
|
||||
// only send on 1st and 6th bettor
|
||||
if (
|
||||
uniqueBettorsExcludingCreator.length !== 1 &&
|
||||
uniqueBettorsExcludingCreator.length !== 6
|
||||
)
|
||||
return
|
||||
const totalNewBettorsToReport =
|
||||
uniqueBettorsExcludingCreator.length === 1 ? 1 : 5
|
||||
|
||||
const mostRecentUniqueBettors = await getValues<User>(
|
||||
firestore
|
||||
.collection('users')
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
uniqueBettorsExcludingCreator.slice(
|
||||
uniqueBettorsExcludingCreator.length - totalNewBettorsToReport,
|
||||
uniqueBettorsExcludingCreator.length
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const bets = await getValues<Bet>(
|
||||
firestore.collection('contracts').doc(contract.id).collection('bets')
|
||||
)
|
||||
// group bets by bettors
|
||||
const bettorsToTheirBets = groupBy(bets, (bet) => bet.userId)
|
||||
await sendNewUniqueBettorsEmail(
|
||||
'unique_bettors_on_your_contract',
|
||||
contractCreatorId,
|
||||
privateUser,
|
||||
contract,
|
||||
uniqueBettorsExcludingCreator.length,
|
||||
mostRecentUniqueBettors,
|
||||
bettorsToTheirBets,
|
||||
Math.round(amount * totalNewBettorsToReport)
|
||||
)
|
||||
}
|
||||
|
||||
export const createNewContractNotification = async (
|
||||
contractCreator: User,
|
||||
contract: Contract,
|
||||
idempotencyKey: string,
|
||||
text: string,
|
||||
mentionedUserIds: string[]
|
||||
) => {
|
||||
if (contract.visibility !== 'public') return
|
||||
|
||||
const sendNotificationsIfSettingsAllow = async (
|
||||
userId: string,
|
||||
reason: notification_reason_types
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
if (sendToBrowser) {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${userId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: userId,
|
||||
reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: contract.id,
|
||||
sourceType: 'contract',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: contractCreator.name,
|
||||
sourceUserUsername: contractCreator.username,
|
||||
sourceUserAvatarUrl: contractCreator.avatarUrl,
|
||||
sourceText: text,
|
||||
sourceSlug: contract.slug,
|
||||
sourceTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
if (!sendToEmail) return
|
||||
if (reason === 'contract_from_followed_user')
|
||||
await sendNewFollowedMarketEmail(reason, userId, privateUser, contract)
|
||||
}
|
||||
const followersSnapshot = await firestore
|
||||
.collectionGroup('follows')
|
||||
.where('userId', '==', contractCreator.id)
|
||||
.get()
|
||||
|
||||
const followerUserIds = filterDefined(
|
||||
followersSnapshot.docs.map((doc) => {
|
||||
const followerUserId = doc.ref.parent.parent?.id
|
||||
return followerUserId && followerUserId != contractCreator.id
|
||||
? followerUserId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
|
||||
// As it is coded now, the tag notification usurps the new contract notification
|
||||
// It'd be easy to append the reason to the eventId if desired
|
||||
for (const followerUserId of followerUserIds) {
|
||||
await sendNotificationsIfSettingsAllow(
|
||||
followerUserId,
|
||||
'contract_from_followed_user'
|
||||
)
|
||||
}
|
||||
for (const mentionedUserId of mentionedUserIds) {
|
||||
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
getDefaultNotificationSettings,
|
||||
PrivateUser,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues } from './utils'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import {
|
||||
|
@ -22,6 +18,7 @@ import { track } from './analytics'
|
|||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group } from '../../common/group'
|
||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||
import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences'
|
||||
|
||||
const bodySchema = z.object({
|
||||
deviceToken: z.string().optional(),
|
||||
|
@ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
email,
|
||||
initialIpAddress: req.ip,
|
||||
initialDeviceToken: deviceToken,
|
||||
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
|
||||
notificationPreferences: getDefaultNotificationPreferences(auth.uid),
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||
|
|
|
@ -1,321 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||
<div style="background-color:#F4F4F4;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
||||
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
width="550"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hi {{name}},</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||
using Manifold Markets. Running low
|
||||
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||
<a href="{{manalink}}" target="_blank"
|
||||
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
|
||||
Claim M$500
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides making correct predictions, there are
|
||||
plenty of other ways to earn mana?</span></p>
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||
tips on comments</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||
trader bonus for each user who bets on your
|
||||
markets</span></li>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||
friends</u></span></a></span></li>
|
||||
<li style="line-height:23px;"><a class="link-build-content"
|
||||
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||
feedback</u></span></a></li>
|
||||
</ul>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"></a></li>
|
||||
</ul>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
||||
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -494,7 +494,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -443,7 +443,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -529,7 +529,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -369,7 +369,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -487,7 +487,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -369,7 +369,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</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 unsubscribe from this type of notification</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>
|
|
@ -502,7 +502,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</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 unsubscribe from this type of notification</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 unsubscribe from this type of notification</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 unsubscribe from this type of notification</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>
|
|
@ -283,7 +283,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -218,7 +218,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -290,7 +290,7 @@
|
|||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants'
|
|||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { Contract } from '../../common/contract'
|
||||
import {
|
||||
notification_subscription_types,
|
||||
PrivateUser,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
|
@ -18,10 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
|
|||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getUser } from './utils'
|
||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||
import { notification_reason_types } from '../../common/notification'
|
||||
import { Dictionary } from 'lodash'
|
||||
import {
|
||||
notification_reason_types,
|
||||
getDestinationsForUser,
|
||||
} from '../../common/notification'
|
||||
getNotificationDestinationsForUser,
|
||||
notification_preference,
|
||||
} from '../../common/user-notification-preferences'
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
reason: notification_reason_types,
|
||||
|
@ -35,8 +33,10 @@ export const sendMarketResolutionEmail = async (
|
|||
resolutionProbability?: number,
|
||||
resolutions?: { [outcome: string]: number }
|
||||
) => {
|
||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||
await getDestinationsForUser(privateUser, reason)
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||
|
||||
const user = await getUser(privateUser.id)
|
||||
|
@ -56,10 +56,9 @@ export const sendMarketResolutionEmail = async (
|
|||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
: ''
|
||||
|
||||
const displayedInvestment =
|
||||
Number.isNaN(investment) || investment < 0
|
||||
? formatMoney(0)
|
||||
: formatMoney(investment)
|
||||
const correctedInvestment =
|
||||
Number.isNaN(investment) || investment < 0 ? 0 : investment
|
||||
const displayedInvestment = formatMoney(correctedInvestment)
|
||||
|
||||
const displayedPayout = formatMoney(payout)
|
||||
|
||||
|
@ -81,7 +80,7 @@ export const sendMarketResolutionEmail = async (
|
|||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-resolved',
|
||||
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
|
||||
templateData
|
||||
)
|
||||
}
|
||||
|
@ -154,7 +153,7 @@ export const sendWelcomeEmail = async (
|
|||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as keyof notification_subscription_types
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
|
||||
return await sendTemplateEmail(
|
||||
|
@ -214,7 +213,7 @@ export const sendOneWeekBonusEmail = async (
|
|||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
|
||||
|
@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
|
|||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as keyof notification_subscription_types
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -247,7 +246,7 @@ export const sendCreatorGuideEmail = async (
|
|||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
|
||||
|
@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
|
|||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as keyof notification_subscription_types
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -279,7 +278,7 @@ export const sendThankYouEmail = async (
|
|||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes(
|
||||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
||||
'email'
|
||||
)
|
||||
)
|
||||
|
@ -289,7 +288,7 @@ export const sendThankYouEmail = async (
|
|||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'thank_you_for_purchases' as keyof notification_subscription_types
|
||||
'thank_you_for_purchases' as notification_preference
|
||||
}`
|
||||
|
||||
return await sendTemplateEmail(
|
||||
|
@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async (
|
|||
privateUser: PrivateUser,
|
||||
contract: Contract
|
||||
) => {
|
||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||
await getDestinationsForUser(privateUser, reason)
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
|
||||
|
@ -350,8 +351,10 @@ export const sendNewCommentEmail = async (
|
|||
answerText?: string,
|
||||
answerId?: string
|
||||
) => {
|
||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||
await getDestinationsForUser(privateUser, reason)
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||
|
||||
const { question } = contract
|
||||
|
@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async (
|
|||
// Don't send the creator's own answers.
|
||||
if (privateUser.id === creatorId) return
|
||||
|
||||
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||
await getDestinationsForUser(privateUser, reason)
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
|
||||
const { question, creatorUsername, slug } = contract
|
||||
|
@ -460,14 +465,12 @@ export const sendInterestingMarketsEmail = async (
|
|||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationSubscriptionTypes.trending_markets.includes(
|
||||
'email'
|
||||
)
|
||||
!privateUser.notificationPreferences.trending_markets.includes('email')
|
||||
)
|
||||
return
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'trending_markets' as keyof notification_subscription_types
|
||||
'trending_markets' as notification_preference
|
||||
}`
|
||||
|
||||
const { name } = user
|
||||
|
@ -511,3 +514,101 @@ function contractUrl(contract: Contract) {
|
|||
function imageSourceUrl(contract: Contract) {
|
||||
return buildCardUrl(getOpenGraphProps(contract))
|
||||
}
|
||||
|
||||
export const sendNewFollowedMarketEmail = async (
|
||||
reason: notification_reason_types,
|
||||
userId: string,
|
||||
privateUser: PrivateUser,
|
||||
contract: Contract
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
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, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
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 { acceptchallenge } from './accept-challenge'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
|||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -119,4 +121,5 @@ export {
|
|||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
createPostFunction as createpost,
|
||||
saveTwitchCredentials as savetwitchcredentials
|
||||
}
|
||||
|
|
|
@ -24,12 +24,17 @@ import {
|
|||
} from '../../common/antes'
|
||||
import { APIError } from '../../common/api'
|
||||
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'
|
||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||
|
||||
export const onCreateBet = functions.firestore
|
||||
.document('contracts/{contractId}/bets/{betId}')
|
||||
export const onCreateBet = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('contracts/{contractId}/bets/{betId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { contractId } = context.params as {
|
||||
contractId: string
|
||||
|
@ -58,6 +63,12 @@ export const onCreateBet = functions.firestore
|
|||
const bettor = await getUser(bet.userId)
|
||||
if (!bettor) return
|
||||
|
||||
await change.ref.update({
|
||||
userAvatarUrl: bettor.avatarUrl,
|
||||
userName: bettor.name,
|
||||
userUsername: bettor.username,
|
||||
})
|
||||
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
|
||||
await notifyFills(bet, contract, eventId, bettor)
|
||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||
|
@ -71,12 +82,16 @@ const updateBettingStreak = async (
|
|||
contract: Contract,
|
||||
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
|
||||
|
||||
// If they've already bet after the reset time, or if we haven't hit the reset time yet
|
||||
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
|
||||
return
|
||||
// If they've already bet after the reset time
|
||||
if (lastBetTime > betStreakResetTime) return
|
||||
|
||||
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
||||
// Otherwise, add 1 to their betting streak
|
||||
|
@ -95,6 +110,7 @@ const updateBettingStreak = async (
|
|||
const bonusTxnDetails = {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
}
|
||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUserId,
|
||||
|
@ -105,11 +121,14 @@ const updateBettingStreak = async (
|
|||
token: 'M$',
|
||||
category: 'BETTING_STREAK_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
if (!result.txn) {
|
||||
log("betting streak bonus txn couldn't be made")
|
||||
log('status:', result.status)
|
||||
log('message:', result.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -119,6 +138,7 @@ const updateBettingStreak = async (
|
|||
bet,
|
||||
contract,
|
||||
bonusAmount,
|
||||
newBettingStreak,
|
||||
eventId
|
||||
)
|
||||
}
|
||||
|
@ -148,12 +168,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
}
|
||||
|
||||
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
||||
|
||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
||||
|
||||
// Update contract unique bettors
|
||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||
|
||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
|
@ -163,10 +184,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
// No need to give a bonus for the creator's bet
|
||||
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
|
||||
const bonusTxnDetails = {
|
||||
contractId: contract.id,
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueNewBettorId: bettor.id,
|
||||
}
|
||||
const fromUserId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
@ -174,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||
const fromUser = fromSnap.data() as User
|
||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
|
@ -184,12 +210,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
token: 'M$',
|
||||
category: 'UNIQUE_BETTOR_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
|
||||
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
|
||||
log('message:', result.message)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
||||
await createUniqueBettorBonusNotification(
|
||||
|
@ -198,6 +226,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
result.txn.id,
|
||||
contract,
|
||||
result.txn.amount,
|
||||
newUniqueBettorIds,
|
||||
eventId + '-unique-bettor-bonus'
|
||||
)
|
||||
}
|
||||
|
@ -244,6 +273,6 @@ const notifyFills = async (
|
|||
)
|
||||
}
|
||||
|
||||
const getTodaysBettingStreakResetTime = () => {
|
||||
const currentDateBettingStreakResetTime = () => {
|
||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createNewContractNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
|
@ -21,13 +21,11 @@ export const onCreateContract = functions
|
|||
const mentioned = parseMentions(desc)
|
||||
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'created',
|
||||
await createNewContractNotification(
|
||||
contractCreator,
|
||||
contract,
|
||||
eventId,
|
||||
richTextToString(desc),
|
||||
{ contract, recipients: mentioned }
|
||||
mentioned
|
||||
)
|
||||
})
|
||||
|
|
|
@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy'
|
|||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
UNIQUE_BETTOR_LIQUIDITY_AMOUNT,
|
||||
} from '../../common/antes'
|
||||
|
||||
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
|
||||
if (
|
||||
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||
liquidity.isAnte ||
|
||||
((liquidity.userId === 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
|
||||
|
||||
|
|
|
@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
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.')
|
||||
|
||||
if (makers) {
|
||||
|
|
|
@ -9,19 +9,25 @@ import {
|
|||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getUser, isProd, payUser } from './utils'
|
||||
import { getUser, getValues, isProd, log, payUser } from './utils'
|
||||
import {
|
||||
getLoanPayouts,
|
||||
getPayouts,
|
||||
groupPayoutsByUser,
|
||||
Payout,
|
||||
} from '../../common/payouts'
|
||||
import { isManifoldId } from '../../common/envs/constants'
|
||||
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { getContractBetMetrics } from '../../common/calculate'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -76,13 +82,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, closeTime } = contract
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||
contract,
|
||||
req.body
|
||||
)
|
||||
|
||||
if (creatorId !== auth.uid && !isManifoldId(auth.uid))
|
||||
if (
|
||||
creatorId !== auth.uid &&
|
||||
!isManifoldId(auth.uid) &&
|
||||
!isAdmin(firebaseUser.email)
|
||||
)
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||
|
@ -158,6 +169,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
await processPayouts(liquidityPayouts, true)
|
||||
|
||||
await processPayouts([...payouts, ...loanPayouts])
|
||||
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
|
@ -166,7 +178,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
(bets) => getContractBetMetrics(contract, bets).invested
|
||||
)
|
||||
let resolutionText = outcome ?? contract.question
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
if (
|
||||
contract.outcomeType === 'FREE_RESPONSE' ||
|
||||
contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||
) {
|
||||
const answerText = contract.answers.find(
|
||||
(answer) => answer.id === outcome
|
||||
)?.text
|
||||
|
@ -291,4 +306,55 @@ function validateAnswer(
|
|||
}
|
||||
}
|
||||
|
||||
async function undoUniqueBettorRewardsIfCancelResolution(
|
||||
contract: Contract,
|
||||
outcome: string
|
||||
) {
|
||||
if (outcome === 'CANCEL') {
|
||||
const creatorsBonusTxns = await getValues<Txn>(
|
||||
firestore
|
||||
.collection('txns')
|
||||
.where('category', '==', 'UNIQUE_BETTOR_BONUS')
|
||||
.where('toId', '==', contract.creatorId)
|
||||
)
|
||||
|
||||
const bonusTxnsOnThisContract = creatorsBonusTxns.filter(
|
||||
(txn) => txn.data && txn.data.contractId === contract.id
|
||||
)
|
||||
log('total bonusTxnsOnThisContract', bonusTxnsOnThisContract.length)
|
||||
const totalBonusAmount = sumBy(bonusTxnsOnThisContract, (txn) => txn.amount)
|
||||
log('totalBonusAmount to be withdrawn', totalBonusAmount)
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: contract.creatorId,
|
||||
fromType: 'USER',
|
||||
toId: isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
toType: 'BANK',
|
||||
amount: totalBonusAmount,
|
||||
token: 'M$',
|
||||
category: 'CANCEL_UNIQUE_BETTOR_BONUS',
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
},
|
||||
} as Omit<CancelUniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
log(
|
||||
`Couldn't cancel bonus for user: ${contract.creatorId} - status:`,
|
||||
result.status
|
||||
)
|
||||
log('message:', result.message)
|
||||
} else {
|
||||
log(
|
||||
`Cancel Bonus txn for user: ${contract.creatorId} completed:`,
|
||||
result.txn?.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
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()
|
|
@ -1,8 +1,8 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getDefaultNotificationSettings } from 'common/user'
|
||||
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
|
||||
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
@ -17,7 +17,7 @@ async function main() {
|
|||
.collection('private-users')
|
||||
.doc(privateUser.id)
|
||||
.update({
|
||||
notificationSubscriptionTypes: getDefaultNotificationSettings(
|
||||
notificationPreferences: getDefaultNotificationPreferences(
|
||||
privateUser.id,
|
||||
privateUser,
|
||||
disableEmails
|
||||
|
|
|
@ -3,8 +3,9 @@ import * as admin from 'firebase-admin'
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { STARTING_BALANCE } from 'common/economy'
|
||||
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -21,7 +22,7 @@ async function main() {
|
|||
id: user.id,
|
||||
email,
|
||||
username,
|
||||
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id),
|
||||
notificationPreferences: getDefaultNotificationPreferences(user.id),
|
||||
}
|
||||
|
||||
if (user.totalDeposits === undefined) {
|
||||
|
|
|
@ -3,12 +3,7 @@
|
|||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import {
|
||||
DocumentCorrespondence,
|
||||
findDiffs,
|
||||
describeDiff,
|
||||
applyDiff,
|
||||
} from './denormalize'
|
||||
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
|
||||
initAdmin()
|
||||
|
@ -79,43 +74,36 @@ if (require.main === module) {
|
|||
getAnswersByUserId(transaction),
|
||||
])
|
||||
|
||||
const usersContracts = Array.from(
|
||||
usersById.entries(),
|
||||
([id, doc]): DocumentCorrespondence => {
|
||||
return [doc, contractsByUserId.get(id) || []]
|
||||
}
|
||||
)
|
||||
const contractDiffs = findDiffs(
|
||||
usersContracts,
|
||||
const usersContracts = Array.from(usersById.entries(), ([id, doc]) => {
|
||||
return [doc, contractsByUserId.get(id) || []] as const
|
||||
})
|
||||
const contractDiffs = findDiffs(usersContracts, [
|
||||
'avatarUrl',
|
||||
'creatorAvatarUrl'
|
||||
)
|
||||
'creatorAvatarUrl',
|
||||
])
|
||||
console.log(`Found ${contractDiffs.length} contracts with mismatches.`)
|
||||
contractDiffs.forEach((d) => {
|
||||
console.log(describeDiff(d))
|
||||
applyDiff(transaction, d)
|
||||
})
|
||||
|
||||
const usersComments = Array.from(
|
||||
usersById.entries(),
|
||||
([id, doc]): DocumentCorrespondence => {
|
||||
return [doc, commentsByUserId.get(id) || []]
|
||||
}
|
||||
)
|
||||
const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl')
|
||||
const usersComments = Array.from(usersById.entries(), ([id, doc]) => {
|
||||
return [doc, commentsByUserId.get(id) || []] as const
|
||||
})
|
||||
const commentDiffs = findDiffs(usersComments, [
|
||||
'avatarUrl',
|
||||
'userAvatarUrl',
|
||||
])
|
||||
console.log(`Found ${commentDiffs.length} comments with mismatches.`)
|
||||
commentDiffs.forEach((d) => {
|
||||
console.log(describeDiff(d))
|
||||
applyDiff(transaction, d)
|
||||
})
|
||||
|
||||
const usersAnswers = Array.from(
|
||||
usersById.entries(),
|
||||
([id, doc]): DocumentCorrespondence => {
|
||||
return [doc, answersByUserId.get(id) || []]
|
||||
}
|
||||
)
|
||||
const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl')
|
||||
const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => {
|
||||
return [doc, answersByUserId.get(id) || []] as const
|
||||
})
|
||||
const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl'])
|
||||
console.log(`Found ${answerDiffs.length} answers with mismatches.`)
|
||||
answerDiffs.forEach((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 { zip } from 'lodash'
|
||||
import { initAdmin } from './script-init'
|
||||
import {
|
||||
DocumentCorrespondence,
|
||||
findDiffs,
|
||||
describeDiff,
|
||||
applyDiff,
|
||||
} from './denormalize'
|
||||
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||
import { log } from '../utils'
|
||||
import { Transaction } from 'firebase-admin/firestore'
|
||||
|
||||
|
@ -41,17 +36,20 @@ async function denormalize() {
|
|||
)
|
||||
)
|
||||
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')
|
||||
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
|
||||
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
|
||||
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
|
||||
const diffs = amountDiffs.concat(outcomeDiffs)
|
||||
// dev DB has some invalid bet IDs
|
||||
const mapping = zip(bets, betComments)
|
||||
.filter(([bet, _]) => bet!.exists) // eslint-disable-line
|
||||
.map(([bet, comment]) => {
|
||||
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) => {
|
||||
log(describeDiff(d))
|
||||
applyDiff(trans, d)
|
||||
|
|
|
@ -2,12 +2,7 @@
|
|||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import {
|
||||
DocumentCorrespondence,
|
||||
findDiffs,
|
||||
describeDiff,
|
||||
applyDiff,
|
||||
} from './denormalize'
|
||||
import { findDiffs, describeDiff, applyDiff } from './denormalize'
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
|
||||
initAdmin()
|
||||
|
@ -43,16 +38,15 @@ async function denormalize() {
|
|||
getContractsById(transaction),
|
||||
getCommentsByContractId(transaction),
|
||||
])
|
||||
const mapping = Object.entries(contractsById).map(
|
||||
([id, doc]): DocumentCorrespondence => {
|
||||
return [doc, commentsByContractId.get(id) || []]
|
||||
}
|
||||
const mapping = Object.entries(contractsById).map(([id, doc]) => {
|
||||
return [doc, commentsByContractId.get(id) || []] as const
|
||||
})
|
||||
const diffs = findDiffs(
|
||||
mapping,
|
||||
['slug', 'contractSlug'],
|
||||
['question', 'contractQuestion']
|
||||
)
|
||||
const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug')
|
||||
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)
|
||||
console.log(`Found ${diffs.length} comments with mismatched data.`)
|
||||
diffs.slice(0, 500).forEach((d) => {
|
||||
console.log(describeDiff(d))
|
||||
applyDiff(transaction, d)
|
||||
|
|
|
@ -2,32 +2,40 @@
|
|||
// another set of documents.
|
||||
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
import { isEqual, zip } from 'lodash'
|
||||
import { UpdateSpec } from '../utils'
|
||||
|
||||
export type DocumentValue = {
|
||||
doc: DocumentSnapshot
|
||||
field: string
|
||||
val: unknown
|
||||
fields: string[]
|
||||
vals: unknown[]
|
||||
}
|
||||
export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]]
|
||||
export type DocumentMapping = readonly [
|
||||
DocumentSnapshot,
|
||||
readonly DocumentSnapshot[]
|
||||
]
|
||||
export type DocumentDiff = {
|
||||
src: DocumentValue
|
||||
dest: DocumentValue
|
||||
}
|
||||
|
||||
type PathPair = readonly [string, string]
|
||||
|
||||
export function findDiffs(
|
||||
docs: DocumentCorrespondence[],
|
||||
srcPath: string,
|
||||
destPath: string
|
||||
docs: readonly DocumentMapping[],
|
||||
...paths: PathPair[]
|
||||
) {
|
||||
const diffs: DocumentDiff[] = []
|
||||
const srcPaths = paths.map((p) => p[0])
|
||||
const destPaths = paths.map((p) => p[1])
|
||||
for (const [srcDoc, destDocs] of docs) {
|
||||
const srcVal = srcDoc.get(srcPath)
|
||||
const srcVals = srcPaths.map((p) => srcDoc.get(p))
|
||||
for (const destDoc of destDocs) {
|
||||
const destVal = destDoc.get(destPath)
|
||||
if (destVal !== srcVal) {
|
||||
const destVals = destPaths.map((p) => destDoc.get(p))
|
||||
if (!isEqual(srcVals, destVals)) {
|
||||
diffs.push({
|
||||
src: { doc: srcDoc, field: srcPath, val: srcVal },
|
||||
dest: { doc: destDoc, field: destPath, val: destVal },
|
||||
src: { doc: srcDoc, fields: srcPaths, vals: srcVals },
|
||||
dest: { doc: destDoc, fields: destPaths, vals: destVals },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -37,12 +45,19 @@ export function findDiffs(
|
|||
|
||||
export function describeDiff(diff: DocumentDiff) {
|
||||
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)}`
|
||||
}
|
||||
|
||||
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
||||
const { src, dest } = diff
|
||||
transaction.update(dest.doc.ref, dest.field, src.val)
|
||||
export function getDiffUpdate(diff: DocumentDiff) {
|
||||
return {
|
||||
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)
|
||||
}
|
||||
|
|
34
functions/src/scripts/update-bonus-txn-data-fields.ts
Normal file
34
functions/src/scripts/update-bonus-txn-data-fields.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { Txn } from 'common/txn'
|
||||
import { getValues } from 'functions/src/utils'
|
||||
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
// get all txns
|
||||
const bonusTxns = await getValues<Txn>(
|
||||
firestore
|
||||
.collection('txns')
|
||||
.where('category', 'in', ['UNIQUE_BETTOR_BONUS', 'BETTING_STREAK_BONUS'])
|
||||
)
|
||||
// JSON parse description field and add to data field
|
||||
const updatedTxns = bonusTxns.map((txn) => {
|
||||
txn.data = txn.description && JSON.parse(txn.description)
|
||||
return txn
|
||||
})
|
||||
console.log('updatedTxns', updatedTxns[0])
|
||||
// update txns
|
||||
await Promise.all(
|
||||
updatedTxns.map((txn) => {
|
||||
return firestore.collection('txns').doc(txn.id).update({
|
||||
data: txn.data,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) main().then(() => process.exit())
|
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, {
|
||||
id: newBetDoc.id,
|
||||
userId: user.id,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
userUsername: user.username,
|
||||
userName: user.name,
|
||||
...newBet,
|
||||
})
|
||||
transaction.update(
|
||||
|
|
|
@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
|
|||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
|||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
|
||||
|
|
|
@ -1,79 +1,227 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { EndpointDefinition } from './api'
|
||||
import { getUser } from './utils'
|
||||
import { getPrivateUser } from './utils'
|
||||
import { PrivateUser } from '../../common/user'
|
||||
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||
import { notification_preference } from '../../common/user-notification-preferences'
|
||||
|
||||
export const unsubscribe: EndpointDefinition = {
|
||||
opts: { method: 'GET', minInstances: 1 },
|
||||
handler: async (req, res) => {
|
||||
const id = req.query.id as string
|
||||
let type = req.query.type as string
|
||||
const type = req.query.type as string
|
||||
if (!id || !type) {
|
||||
res.status(400).send('Empty id or type parameter.')
|
||||
res.status(400).send('Empty id or subscription type parameter.')
|
||||
return
|
||||
}
|
||||
console.log(`Unsubscribing ${id} from ${type}`)
|
||||
const notificationSubscriptionType = type as notification_preference
|
||||
if (notificationSubscriptionType === undefined) {
|
||||
res.status(400).send('Invalid subscription type parameter.')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'market-resolved') type = 'market-resolve'
|
||||
|
||||
if (
|
||||
![
|
||||
'market-resolve',
|
||||
'market-comment',
|
||||
'market-answer',
|
||||
'generic',
|
||||
'weekly-trending',
|
||||
].includes(type)
|
||||
) {
|
||||
res.status(400).send('Invalid type parameter.')
|
||||
return
|
||||
}
|
||||
|
||||
const user = await getUser(id)
|
||||
const user = await getPrivateUser(id)
|
||||
|
||||
if (!user) {
|
||||
res.send('This user is not currently subscribed or does not exist.')
|
||||
return
|
||||
}
|
||||
|
||||
const { name } = user
|
||||
const previousDestinations =
|
||||
user.notificationPreferences[notificationSubscriptionType]
|
||||
|
||||
console.log(previousDestinations)
|
||||
const { email } = user
|
||||
|
||||
const update: Partial<PrivateUser> = {
|
||||
...(type === 'market-resolve' && {
|
||||
unsubscribedFromResolutionEmails: true,
|
||||
}),
|
||||
...(type === 'market-comment' && {
|
||||
unsubscribedFromCommentEmails: true,
|
||||
}),
|
||||
...(type === 'market-answer' && {
|
||||
unsubscribedFromAnswerEmails: true,
|
||||
}),
|
||||
...(type === 'generic' && {
|
||||
unsubscribedFromGenericEmails: true,
|
||||
}),
|
||||
...(type === 'weekly-trending' && {
|
||||
unsubscribedFromWeeklyTrendingEmails: true,
|
||||
}),
|
||||
notificationPreferences: {
|
||||
...user.notificationPreferences,
|
||||
[notificationSubscriptionType]: previousDestinations.filter(
|
||||
(destination) => destination !== 'email'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
|
||||
if (type === 'market-resolve')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-comment')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-answer')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'weekly-trending')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
|
||||
)
|
||||
else res.send(`${name}, you have been unsubscribed.`)
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||
<div style="background-color:#F4F4F4;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hello!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
${email} has been unsubscribed from email notifications related to:
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
|
||||
</p>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||
to manage the rest of your notification settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p></p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button'
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
||||
export function AnswerResolvePanel(props: {
|
||||
isAdmin: boolean
|
||||
isCreator: boolean
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||
setResolveOption: (
|
||||
|
@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: {
|
|||
) => void
|
||||
chosenAnswers: { [answerId: string]: number }
|
||||
}) {
|
||||
const { contract, resolveOption, setResolveOption, chosenAnswers } = props
|
||||
const {
|
||||
contract,
|
||||
resolveOption,
|
||||
setResolveOption,
|
||||
chosenAnswers,
|
||||
isAdmin,
|
||||
isCreator,
|
||||
} = props
|
||||
const answers = Object.keys(chosenAnswers)
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: {
|
|||
|
||||
return (
|
||||
<Col className="gap-4 rounded">
|
||||
<div>Resolve your market</div>
|
||||
<Row className="justify-between">
|
||||
<div>Resolve your market</div>
|
||||
{isAdmin && !isCreator && (
|
||||
<span className="rounded bg-red-200 p-1 text-xs text-red-600">
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Col className="gap-4 sm:flex-row sm:items-center">
|
||||
<ChooseCancelSelector
|
||||
className="sm:!flex-row sm:items-center"
|
||||
|
|
|
@ -24,24 +24,25 @@ import { Linkify } from 'web/components/linkify'
|
|||
import { BuyButton } from 'web/components/yes-no-selector'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
||||
|
||||
export function AnswersPanel(props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
}) {
|
||||
const isAdmin = useAdmin()
|
||||
const { contract } = props
|
||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||
contract
|
||||
const [showAllAnswers, setShowAllAnswers] = useState(false)
|
||||
|
||||
const answers = useAnswers(contract.id) ?? contract.answers
|
||||
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0)
|
||||
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 [winningAnswers, losingAnswers] = partition(
|
||||
answers.filter((answer) =>
|
||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers
|
||||
? true
|
||||
: totalBets[answer.id] > 0
|
||||
),
|
||||
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
|
||||
(answer) =>
|
||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||
)
|
||||
|
@ -156,17 +157,20 @@ export function AnswersPanel(props: {
|
|||
<CreateAnswerPanel contract={contract} />
|
||||
)}
|
||||
|
||||
{user?.id === creatorId && !resolution && (
|
||||
<>
|
||||
<Spacer h={2} />
|
||||
<AnswerResolvePanel
|
||||
contract={contract}
|
||||
resolveOption={resolveOption}
|
||||
setResolveOption={setResolveOption}
|
||||
chosenAnswers={chosenAnswers}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
|
||||
!resolution && (
|
||||
<>
|
||||
<Spacer h={2} />
|
||||
<AnswerResolvePanel
|
||||
isAdmin={isAdmin}
|
||||
isCreator={user?.id === creatorId}
|
||||
contract={contract}
|
||||
resolveOption={resolveOption}
|
||||
setResolveOption={setResolveOption}
|
||||
chosenAnswers={chosenAnswers}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
|
|||
if (!isArray(sections)) sections = []
|
||||
|
||||
const items = [
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'New for you', id: 'newest' },
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
|
|||
import { Col } from './layout/col'
|
||||
import { Button } from 'web/components/button'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { PRESENT_BET } from 'common/user'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -36,12 +37,12 @@ export default function BetButton(props: {
|
|||
<Button
|
||||
size="lg"
|
||||
className={clsx(
|
||||
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
|
||||
'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Predict
|
||||
{PRESENT_BET}
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
|
|
@ -79,7 +79,7 @@ export function BetInline(props: {
|
|||
return (
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
|
||||
<div className="text-xl">Bet</div>
|
||||
<div className="text-xl">Predict</div>
|
||||
<YesNoSelector
|
||||
className="space-x-0"
|
||||
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
|
|
|
@ -754,7 +754,10 @@ function SellButton(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function ProfitBadge(props: { profitPercent: number; className?: string }) {
|
||||
export function ProfitBadge(props: {
|
||||
profitPercent: number
|
||||
className?: string
|
||||
}) {
|
||||
const { profitPercent, className } = props
|
||||
if (!profitPercent) return null
|
||||
const colors =
|
||||
|
|
|
@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite'
|
|||
import { SearchOptions } from '@algolia/client-search'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Contract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { PAST_BETS, User } from 'common/user'
|
||||
import {
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
|
@ -41,7 +41,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
|||
export const SORTS = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Trending', value: 'score' },
|
||||
{ label: 'Most traded', value: 'most-traded' },
|
||||
{ label: `Most ${PAST_BETS}`, value: 'most-traded' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: '24h change', value: 'prob-change-day' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
|
@ -164,6 +164,7 @@ export function ContractSearch(props: {
|
|||
numericFilters,
|
||||
page: requestedPage,
|
||||
hitsPerPage: 20,
|
||||
advancedSyntax: true,
|
||||
})
|
||||
// if there's a more recent request, forget about this one
|
||||
if (id === requestId.current) {
|
||||
|
@ -200,7 +201,7 @@ export function ContractSearch(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Col className="h-full">
|
||||
<Col>
|
||||
<ContractSearchControls
|
||||
className={headerClassName}
|
||||
defaultSort={defaultSort}
|
||||
|
@ -447,7 +448,7 @@ function ContractSearchControls(props: {
|
|||
selected={state.pillFilter === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your trades
|
||||
Your {PAST_BETS}
|
||||
</PillButton>
|
||||
)}
|
||||
|
||||
|
|
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
|
||||
text={`${formatMoney(
|
||||
volume
|
||||
)} bet - ${uniqueBettors} unique traders`}
|
||||
)} bet - ${uniqueBettors} unique predictors`}
|
||||
>
|
||||
{volumeTranslation}
|
||||
</Tooltip>
|
||||
|
|
|
@ -18,6 +18,7 @@ import { deleteField } from 'firebase/firestore'
|
|||
import ShortToggle from '../widgets/short-toggle'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
import { Row } from '../layout/row'
|
||||
import { BETTORS } from 'common/user'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -135,7 +136,7 @@ export function ContractInfoDialog(props: {
|
|||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Traders</td>
|
||||
<td>{BETTORS}</td>
|
||||
<td>{bettorsCount}</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
@ -6,13 +6,13 @@ import { formatMoney } from 'common/util/format'
|
|||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { useUserById } from 'web/hooks/use-user'
|
||||
import { listUsers, User } from 'web/lib/firebase/users'
|
||||
import { FeedBet } from '../feed/feed-bets'
|
||||
import { FeedComment } from '../feed/feed-comments'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Leaderboard } from '../leaderboard'
|
||||
import { Title } from '../title'
|
||||
import { BETTORS } from 'common/user'
|
||||
|
||||
export function ContractLeaderboard(props: {
|
||||
contract: Contract
|
||||
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title={`🏅 Top ${BETTORS}`}
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
@ -88,7 +88,7 @@ export function ContractTopTrades(props: {
|
|||
|
||||
// Now find the betId with the highest profit
|
||||
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
|
||||
const topCommentId = sortBy(
|
||||
|
@ -121,7 +121,7 @@ export function ContractTopTrades(props: {
|
|||
<FeedBet contract={contract} bet={betsById[topBetId]} />
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { PAST_BETS, User } from 'common/user'
|
||||
import {
|
||||
ContractCommentsActivity,
|
||||
ContractBetsActivity,
|
||||
|
@ -114,13 +114,13 @@ export function ContractTabs(props: {
|
|||
badge: `${comments.length}`,
|
||||
},
|
||||
{
|
||||
title: 'Trades',
|
||||
title: PAST_BETS,
|
||||
content: betActivity,
|
||||
badge: `${visibleBets.length}`,
|
||||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your trades', content: yourTrades }]),
|
||||
: [{ title: `Your ${PAST_BETS}`, content: yourTrades }]),
|
||||
]}
|
||||
/>
|
||||
{!user ? (
|
||||
|
|
|
@ -18,21 +18,22 @@ export const WatchMarketModal = (props: {
|
|||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||
<span className={'ml-2'}>
|
||||
You'll receive notifications on markets by betting, commenting, or
|
||||
clicking the
|
||||
Watching a market means you'll receive notifications from activity
|
||||
on it. You automatically start watching a market if you comment on
|
||||
it, bet on it, or click the
|
||||
<EyeIcon
|
||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
️ button on them.
|
||||
️ button.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• What types of notifications will I receive?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
You'll receive notifications for new comments, answers, and updates
|
||||
to the question. See the notifications settings pages to customize
|
||||
which types of notifications you receive on watched markets.
|
||||
New comments, answers, and updates to the question. See the
|
||||
notifications settings pages to customize which types of
|
||||
notifications you receive on watched markets.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
import { Editor } from '@tiptap/react'
|
||||
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'
|
||||
import { SelectMarketsModal } from '../contract-select-modal'
|
||||
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
||||
import { insertContent } from './utils'
|
||||
|
||||
|
@ -17,83 +11,23 @@ export function MarketModal(props: {
|
|||
}) {
|
||||
const { editor, open, setOpen } = 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 doneAddingContracts() {
|
||||
setLoading(true)
|
||||
function onSubmit(contracts: Contract[]) {
|
||||
if (contracts.length == 1) {
|
||||
insertContent(editor, embedContractCode(contracts[0]))
|
||||
} else if (contracts.length > 1) {
|
||||
insertContent(editor, embedContractGridCode(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">
|
||||
<Row className="p-8 pb-0">
|
||||
<div className={'text-xl text-indigo-700'}>Embed a market</div>
|
||||
|
||||
{!loading && (
|
||||
<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)
|
||||
}
|
||||
}}
|
||||
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 sticky"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Modal>
|
||||
<SelectMarketsModal
|
||||
title="Embed markets"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
submitLabel={(len) =>
|
||||
len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions`
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: {
|
|||
const tweetId = props.node.attrs.tweetId.slice(1)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="tiptap-tweet">
|
||||
<NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto">
|
||||
<TwitterTweetEmbed tweetId={tweetId} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Bet } from 'common/bet'
|
||||
import { User } from 'common/user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
|
@ -15,32 +14,24 @@ import { SiteLink } from 'web/components/site-link'
|
|||
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { BETTOR } from 'common/user'
|
||||
|
||||
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
||||
const { contract, bet } = props
|
||||
const { userId, createdTime } = bet
|
||||
|
||||
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
|
||||
const { userAvatarUrl, userUsername, createdTime } = bet
|
||||
const showUser = dayjs(createdTime).isAfter('2022-06-01')
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-2 pt-3">
|
||||
{isSelf ? (
|
||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
||||
) : bettor ? (
|
||||
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
|
||||
{showUser ? (
|
||||
<Avatar avatarUrl={userAvatarUrl} username={userUsername} />
|
||||
) : (
|
||||
<EmptyAvatar className="mx-1" />
|
||||
)}
|
||||
<BetStatusText
|
||||
bet={bet}
|
||||
contract={contract}
|
||||
isSelf={isSelf}
|
||||
bettor={bettor}
|
||||
hideUser={!showUser}
|
||||
className="flex-1"
|
||||
/>
|
||||
</Row>
|
||||
|
@ -50,13 +41,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
|||
export function BetStatusText(props: {
|
||||
contract: Contract
|
||||
bet: Bet
|
||||
isSelf: boolean
|
||||
bettor?: User
|
||||
hideUser?: boolean
|
||||
hideOutcome?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
|
||||
const { bet, contract, hideUser, hideOutcome, className } = props
|
||||
const { outcomeType } = contract
|
||||
const self = useUser()
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||
const { amount, outcome, createdTime, challengeSlug } = bet
|
||||
|
@ -101,10 +92,10 @@ export function BetStatusText(props: {
|
|||
|
||||
return (
|
||||
<div className={clsx('text-sm text-gray-500', className)}>
|
||||
{bettor ? (
|
||||
<UserLink name={bettor.name} username={bettor.username} />
|
||||
{!hideUser ? (
|
||||
<UserLink name={bet.userName} username={bet.userUsername} />
|
||||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
<span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span>
|
||||
)}{' '}
|
||||
{bought} {money}
|
||||
{outOfTotalAmount}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { PRESENT_BET, User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
|
||||
|
@ -255,7 +255,7 @@ function CommentStatus(props: {
|
|||
const { contract, outcome, prob } = props
|
||||
return (
|
||||
<>
|
||||
{' betting '}
|
||||
{` ${PRESENT_BET}ing `}
|
||||
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
|
||||
{prob && ' at ' + Math.round(prob * 100) + '%'}
|
||||
</>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { User } from 'common/user'
|
||||
import { BETTOR, User } from 'common/user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
|
@ -9,13 +9,17 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
|||
import React from 'react'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from 'common/antes'
|
||||
|
||||
export function FeedLiquidity(props: {
|
||||
className?: string
|
||||
liquidity: LiquidityProvision
|
||||
}) {
|
||||
const { liquidity } = props
|
||||
const { userId, createdTime } = liquidity
|
||||
const { userId, createdTime, isAnte } = liquidity
|
||||
|
||||
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
|
@ -24,8 +28,15 @@ export function FeedLiquidity(props: {
|
|||
const user = useUser()
|
||||
const isSelf = user?.id === userId
|
||||
|
||||
if (
|
||||
isAnte ||
|
||||
userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||
userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
)
|
||||
return <></>
|
||||
|
||||
return (
|
||||
<Row className="flex w-full gap-2 pt-3">
|
||||
<Row className="items-center gap-2 pt-3">
|
||||
{isSelf ? (
|
||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
||||
) : bettor ? (
|
||||
|
@ -63,7 +74,7 @@ export function LiquidityStatusText(props: {
|
|||
{bettor ? (
|
||||
<UserLink name={bettor.name} username={bettor.username} />
|
||||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
<span>{isSelf ? 'You' : `A ${BETTOR}`}</span>
|
||||
)}{' '}
|
||||
{bought} a subsidy of {money}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
|||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { sortBy } from 'lodash'
|
||||
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 { Avatar } from './avatar'
|
||||
import { Button } from './button'
|
||||
|
@ -109,16 +109,14 @@ function LimitBet(props: {
|
|||
setIsCancelling(true)
|
||||
}
|
||||
|
||||
const user = useUserById(bet.userId)
|
||||
|
||||
return (
|
||||
<tr>
|
||||
{!isYou && (
|
||||
<td>
|
||||
<Avatar
|
||||
size={'sm'}
|
||||
avatarUrl={user?.avatarUrl}
|
||||
username={user?.username}
|
||||
avatarUrl={bet.userAvatarUrl}
|
||||
username={bet.userUsername}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label'
|
|||
import { Col } from './layout/col'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { BETTORS, PRESENT_BET } from 'common/user'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
|
@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
<>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Contribute your M$ to make this market more accurate.{' '}
|
||||
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
|
||||
<InfoTooltip
|
||||
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
|
|
|
@ -9,10 +9,11 @@ export function MarketIntroPanel() {
|
|||
<div className="text-xl">Play-money predictions</div>
|
||||
|
||||
<Image
|
||||
height={150}
|
||||
width={150}
|
||||
className="self-center"
|
||||
src="/flappy-logo.gif"
|
||||
height={125}
|
||||
width={125}
|
||||
className="my-4 self-center"
|
||||
src="/welcome/manipurple.png"
|
||||
alt="Manifold Markets gradient logo"
|
||||
/>
|
||||
|
||||
<div className="mb-4 text-sm">
|
||||
|
|
|
@ -19,6 +19,8 @@ import { useIsIframe } from 'web/hooks/use-is-iframe'
|
|||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { User } from 'common/user'
|
||||
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
|
@ -38,7 +40,7 @@ const signedOutNavigation = [
|
|||
export const userProfileItem = (user: User) => ({
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=trades`,
|
||||
href: `/${user.username}?tab=${PAST_BETS}`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
|
|
|
@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}?tab=trades`}>
|
||||
<Link href={`/${user.username}?tab=${PAST_BETS}`}>
|
||||
<a
|
||||
onClick={trackCallback('sidebar: profile')}
|
||||
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
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,
|
||||
} from 'common/user'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import {
|
||||
|
@ -23,22 +18,28 @@ import {
|
|||
UsersIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
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'
|
||||
import { NOTIFICATION_DESCRIPTIONS } from 'common/notification'
|
||||
import {
|
||||
notification_destination_types,
|
||||
notification_preference,
|
||||
} from 'common/user-notification-preferences'
|
||||
|
||||
export function NotificationSettings(props: {
|
||||
navigateToSection: string | undefined
|
||||
privateUser: PrivateUser
|
||||
}) {
|
||||
const { navigateToSection } = props
|
||||
const privateUser = usePrivateUser()
|
||||
const { navigateToSection, privateUser } = props
|
||||
const [showWatchModal, setShowWatchModal] = useState(false)
|
||||
|
||||
if (!privateUser || !privateUser.notificationSubscriptionTypes) {
|
||||
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
||||
}
|
||||
|
||||
const emailsEnabled: Array<keyof notification_subscription_types> = [
|
||||
const emailsEnabled: Array<notification_preference> = [
|
||||
'all_comments_on_watched_markets',
|
||||
'all_replies_to_my_comments_on_watched_markets',
|
||||
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||
|
@ -58,135 +59,127 @@ export function NotificationSettings(props: {
|
|||
'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
|
||||
'tagged_user',
|
||||
// 'contract_from_followed_user',
|
||||
// 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',
|
||||
// 'unique_bettors_on_your_contract',
|
||||
// 'on_new_follow',
|
||||
// 'profit_loss_updates',
|
||||
// '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> = [
|
||||
const browserDisabled: Array<notification_preference> = [
|
||||
'trending_markets',
|
||||
'profit_loss_updates',
|
||||
'onboarding_flow',
|
||||
'thank_you_for_purchases',
|
||||
]
|
||||
|
||||
type sectionData = {
|
||||
type SectionData = {
|
||||
label: string
|
||||
subscriptionTypeToDescription: {
|
||||
[key in keyof Partial<notification_subscription_types>]: string
|
||||
}
|
||||
subscriptionTypes: Partial<notification_preference>[]
|
||||
}
|
||||
|
||||
const comments: sectionData = {
|
||||
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`,
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'all_comments_on_watched_markets',
|
||||
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||
// TODO: combine these two
|
||||
'all_replies_to_my_comments_on_watched_markets',
|
||||
'all_replies_to_my_answers_on_watched_markets',
|
||||
],
|
||||
}
|
||||
|
||||
const answers: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'all_answers_on_watched_markets',
|
||||
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||
],
|
||||
}
|
||||
const updates: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'market_updates_on_watched_markets',
|
||||
'market_updates_on_watched_markets_with_shares_in',
|
||||
'resolutions_on_watched_markets',
|
||||
'resolutions_on_watched_markets_with_shares_in',
|
||||
],
|
||||
}
|
||||
const yourMarkets: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'your_contract_closed',
|
||||
'all_comments_on_my_markets',
|
||||
'all_answers_on_my_markets',
|
||||
'subsidized_your_market',
|
||||
'tips_on_your_markets',
|
||||
],
|
||||
}
|
||||
const bonuses: sectionData = {
|
||||
const bonuses: SectionData = {
|
||||
label: 'Bonuses',
|
||||
subscriptionTypeToDescription: {
|
||||
betting_streaks: 'Betting streak bonuses',
|
||||
referral_bonuses: 'Referral bonuses from referring users',
|
||||
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'betting_streaks',
|
||||
'referral_bonuses',
|
||||
'unique_bettors_on_your_contract',
|
||||
],
|
||||
}
|
||||
const otherBalances: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'loan_income',
|
||||
'limit_order_fills',
|
||||
'tips_on_your_comments',
|
||||
],
|
||||
}
|
||||
const userInteractions: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'tagged_user',
|
||||
'on_new_follow',
|
||||
'contract_from_followed_user',
|
||||
],
|
||||
}
|
||||
const generalOther: sectionData = {
|
||||
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',
|
||||
},
|
||||
subscriptionTypes: [
|
||||
'trending_markets',
|
||||
'thank_you_for_purchases',
|
||||
'onboarding_flow',
|
||||
],
|
||||
}
|
||||
|
||||
const NotificationSettingLine = (
|
||||
description: string,
|
||||
key: keyof notification_subscription_types,
|
||||
value: notification_destination_types[]
|
||||
) => {
|
||||
const previousInAppValue = value.includes('browser')
|
||||
const previousEmailValue = value.includes('email')
|
||||
function NotificationSettingLine(props: {
|
||||
description: string
|
||||
subscriptionTypeKey: notification_preference
|
||||
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 === key
|
||||
const highlight = navigateToSection === subscriptionTypeKey
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
inAppEnabled !== previousInAppValue ||
|
||||
emailEnabled !== previousEmailValue
|
||||
) {
|
||||
toast.promise(
|
||||
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
|
||||
toast
|
||||
.promise(
|
||||
updatePrivateUser(privateUser.id, {
|
||||
notificationSubscriptionTypes: {
|
||||
...privateUser.notificationSubscriptionTypes,
|
||||
[key]: filterDefined([
|
||||
inAppEnabled ? 'browser' : undefined,
|
||||
emailEnabled ? 'email' : undefined,
|
||||
]),
|
||||
notificationPreferences: {
|
||||
...privateUser.notificationPreferences,
|
||||
[subscriptionTypeKey]: destinations.includes(setting)
|
||||
? destinations.filter((d) => d !== setting)
|
||||
: uniq([...destinations, setting]),
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
@ -195,14 +188,14 @@ export function NotificationSettings(props: {
|
|||
error: 'Error changing notification settings. Try again?',
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [
|
||||
inAppEnabled,
|
||||
emailEnabled,
|
||||
previousInAppValue,
|
||||
previousEmailValue,
|
||||
key,
|
||||
])
|
||||
.then(() => {
|
||||
if (setting === 'browser') {
|
||||
setInAppEnabled(newValue)
|
||||
} else {
|
||||
setEmailEnabled(newValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
|
@ -216,17 +209,17 @@ export function NotificationSettings(props: {
|
|||
<span>{description}</span>
|
||||
</Row>
|
||||
<Row className={'gap-4'}>
|
||||
{!browserDisabled.includes(key) && (
|
||||
{!browserDisabled.includes(subscriptionTypeKey) && (
|
||||
<SwitchSetting
|
||||
checked={inAppEnabled}
|
||||
onChange={setInAppEnabled}
|
||||
onChange={(newVal) => changeSetting('browser', newVal)}
|
||||
label={'Web'}
|
||||
/>
|
||||
)}
|
||||
{emailsEnabled.includes(key) && (
|
||||
{emailsEnabled.includes(subscriptionTypeKey) && (
|
||||
<SwitchSetting
|
||||
checked={emailEnabled}
|
||||
onChange={setEmailEnabled}
|
||||
onChange={(newVal) => changeSetting('email', newVal)}
|
||||
label={'Email'}
|
||||
/>
|
||||
)}
|
||||
|
@ -236,23 +229,31 @@ export function NotificationSettings(props: {
|
|||
)
|
||||
}
|
||||
|
||||
const getUsersSavedPreference = (
|
||||
key: keyof notification_subscription_types
|
||||
) => {
|
||||
return privateUser.notificationSubscriptionTypes[key] ?? []
|
||||
const getUsersSavedPreference = (key: notification_preference) => {
|
||||
return privateUser.notificationPreferences[key] ?? []
|
||||
}
|
||||
|
||||
const Section = (icon: ReactNode, data: sectionData) => {
|
||||
const { label, subscriptionTypeToDescription } = data
|
||||
const Section = memo(function Section(props: {
|
||||
icon: ReactNode
|
||||
data: SectionData
|
||||
}) {
|
||||
const { icon, data } = props
|
||||
const { label, subscriptionTypes } = data
|
||||
const expand =
|
||||
navigateToSection &&
|
||||
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
|
||||
const [expanded, setExpanded] = useState(expand)
|
||||
subscriptionTypes.includes(navigateToSection as notification_preference)
|
||||
|
||||
// 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-' + subscriptionTypes.join('-'),
|
||||
store: storageStore(safeLocalStorage()),
|
||||
})
|
||||
|
||||
// Not working as the default value for expanded, so using a useEffect
|
||||
useEffect(() => {
|
||||
if (expand) setExpanded(true)
|
||||
}, [expand])
|
||||
}, [expand, setExpanded])
|
||||
|
||||
return (
|
||||
<Col className={clsx('ml-2 gap-2')}>
|
||||
|
@ -274,19 +275,19 @@ export function NotificationSettings(props: {
|
|||
)}
|
||||
</Row>
|
||||
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
||||
{Object.entries(subscriptionTypeToDescription).map(([key, value]) =>
|
||||
NotificationSettingLine(
|
||||
value,
|
||||
key as keyof notification_subscription_types,
|
||||
getUsersSavedPreference(
|
||||
key as keyof notification_subscription_types
|
||||
)
|
||||
)
|
||||
)}
|
||||
{subscriptionTypes.map((subType) => (
|
||||
<NotificationSettingLine
|
||||
subscriptionTypeKey={subType as notification_preference}
|
||||
destinations={getUsersSavedPreference(
|
||||
subType as notification_preference
|
||||
)}
|
||||
description={NOTIFICATION_DESCRIPTIONS[subType].simple}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={'p-2'}>
|
||||
|
@ -298,20 +299,38 @@ export function NotificationSettings(props: {
|
|||
onClick={() => setShowWatchModal(true)}
|
||||
/>
|
||||
</Row>
|
||||
{Section(<ChatIcon className={'h-6 w-6'} />, comments)}
|
||||
{Section(<LightBulbIcon className={'h-6 w-6'} />, answers)}
|
||||
{Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)}
|
||||
{Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)}
|
||||
<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(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)}
|
||||
{Section(<CashIcon className={'h-6 w-6'} />, otherBalances)}
|
||||
<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(<UsersIcon className={'h-6 w-6'} />, userInteractions)}
|
||||
{Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)}
|
||||
<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>
|
||||
|
|
|
@ -10,13 +10,16 @@ import { NumericContract, PseudoNumericContract } from 'common/contract'
|
|||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||
import { BucketInput } from './bucket-input'
|
||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
|
||||
|
||||
export function NumericResolutionPanel(props: {
|
||||
isAdmin: boolean
|
||||
isCreator: boolean
|
||||
creator: User
|
||||
contract: NumericContract | PseudoNumericContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const { contract, className, isAdmin, isCreator } = props
|
||||
const { min, max, outcomeType } = contract
|
||||
|
||||
const [outcomeMode, setOutcomeMode] = useState<
|
||||
|
@ -78,10 +81,20 @@ export function NumericResolutionPanel(props: {
|
|||
: 'btn-disabled'
|
||||
|
||||
return (
|
||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
||||
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
|
||||
<Col
|
||||
className={clsx(
|
||||
'relative w-full rounded-md bg-white px-8 py-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isAdmin && !isCreator && (
|
||||
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
<div className="whitespace-nowrap text-2xl">Resolve market</div>
|
||||
|
||||
<div className="mb-3 text-sm text-gray-500">Outcome</div>
|
||||
<div className="my-3 text-sm text-gray-500">Outcome</div>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
|
@ -99,9 +112,12 @@ export function NumericResolutionPanel(props: {
|
|||
|
||||
<div>
|
||||
{outcome === 'CANCEL' ? (
|
||||
<>All trades will be returned with no fees.</>
|
||||
<>
|
||||
All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
|
||||
withdrawn from your account
|
||||
</>
|
||||
) : (
|
||||
<>Resolving this market will immediately pay out traders.</>
|
||||
<>Resolving this market will immediately pay out {BETTORS}.</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -3,26 +3,52 @@ import { Col } from 'web/components/layout/col'
|
|||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
BETTING_STREAK_BONUS_MAX,
|
||||
BETTING_STREAK_RESET_HOUR,
|
||||
} from 'common/economy'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { User } from 'common/user'
|
||||
import dayjs from 'dayjs'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function BettingStreakModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
currentUser?: User | null
|
||||
}) {
|
||||
const { isOpen, setOpen } = props
|
||||
const { isOpen, setOpen, currentUser } = props
|
||||
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🔥</span>
|
||||
<span className="text-xl">Daily betting streaks</span>
|
||||
<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'}>
|
||||
<span className={'text-indigo-700'}>• What are they?</span>
|
||||
<span className={'ml-2'}>
|
||||
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
|
||||
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
|
||||
. The more days you bet in a row, the more you earn!
|
||||
of consecutive predicting up to{' '}
|
||||
{formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict
|
||||
in a row, the more you earn!
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• Where can I check my streak?
|
||||
|
@ -36,3 +62,17 @@ export function BettingStreakModal(props: {
|
|||
</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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
export function LoansModal(props: {
|
||||
isOpen: boolean
|
||||
|
@ -11,7 +12,7 @@ export function LoansModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🏦</span>
|
||||
<span className="text-xl">Daily loans on your trades</span>
|
||||
<span className="text-xl">Daily loans on your {PAST_BETS}</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -10,13 +10,16 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
|||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { BinaryContract, resolution } from 'common/contract'
|
||||
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
|
||||
|
||||
export function ResolutionPanel(props: {
|
||||
isAdmin: boolean
|
||||
isCreator: boolean
|
||||
creator: User
|
||||
contract: BinaryContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const { contract, className, isAdmin, isCreator } = props
|
||||
|
||||
// const earnedFees =
|
||||
// contract.mechanism === 'dpm-2'
|
||||
|
@ -66,7 +69,12 @@ export function ResolutionPanel(props: {
|
|||
: 'btn-disabled'
|
||||
|
||||
return (
|
||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
||||
<Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}>
|
||||
{isAdmin && !isCreator && (
|
||||
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
|
||||
|
||||
<div className="mb-3 text-sm text-gray-500">Outcome</div>
|
||||
|
@ -83,23 +91,28 @@ export function ResolutionPanel(props: {
|
|||
<div>
|
||||
{outcome === 'YES' ? (
|
||||
<>
|
||||
Winnings will be paid out to traders who bought YES.
|
||||
Winnings will be paid out to {BETTORS} who bought YES.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'NO' ? (
|
||||
<>
|
||||
Winnings will be paid out to traders who bought NO.
|
||||
Winnings will be paid out to {BETTORS} who bought NO.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'CANCEL' ? (
|
||||
<>All trades will be returned with no fees.</>
|
||||
<>
|
||||
All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
|
||||
withdrawn from your account
|
||||
</>
|
||||
) : outcome === 'MKT' ? (
|
||||
<Col className="gap-6">
|
||||
<div>Traders will be paid out at the probability you specify:</div>
|
||||
<div>
|
||||
{PAST_BETS} will be paid out at the probability you specify:
|
||||
</div>
|
||||
<ProbabilitySelector
|
||||
probabilityInt={Math.round(prob)}
|
||||
setProbabilityInt={setProb}
|
||||
|
@ -107,7 +120,7 @@ export function ResolutionPanel(props: {
|
|||
{/* You will earn {earnedFees}. */}
|
||||
</Col>
|
||||
) : (
|
||||
<>Resolving this market will immediately pay out traders.</>
|
||||
<>Resolving this market will immediately pay out {BETTORS}.</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -25,13 +25,17 @@ import { UserFollowButton } from './follow-button'
|
|||
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||
import { ReferralsButton } from 'web/components/referrals-button'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { capitalFirst, formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
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 { LoansModal } from './profile/loans-modal'
|
||||
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
export function UserPage(props: { user: User }) {
|
||||
const { user } = props
|
||||
|
@ -83,6 +87,7 @@ export function UserPage(props: { user: User }) {
|
|||
<BettingStreakModal
|
||||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
|
@ -139,7 +144,12 @@ export function UserPage(props: { user: User }) {
|
|||
<span>profit</span>
|
||||
</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)}
|
||||
>
|
||||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||
|
@ -260,7 +270,7 @@ export function UserPage(props: { user: User }) {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Trades',
|
||||
title: capitalFirst(PAST_BETS),
|
||||
content: (
|
||||
<>
|
||||
<BetsList user={user} />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEvent } from '../hooks/use-event'
|
||||
|
||||
export function VisibilityObserver(props: {
|
||||
|
@ -8,18 +8,16 @@ export function VisibilityObserver(props: {
|
|||
const { className } = props
|
||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
|
||||
const observer = useRef(
|
||||
new IntersectionObserver(([entry]) => {
|
||||
onVisibilityUpdated(entry.isIntersecting)
|
||||
}, {})
|
||||
).current
|
||||
|
||||
useEffect(() => {
|
||||
if (elem) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
onVisibilityUpdated(entry.isIntersecting)
|
||||
}, {})
|
||||
observer.observe(elem)
|
||||
return () => observer.unobserve(elem)
|
||||
}
|
||||
}, [elem, observer])
|
||||
}, [elem, onVisibilityUpdated])
|
||||
|
||||
return <div ref={setElem} className={className}></div>
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Contract,
|
||||
listenForActiveContracts,
|
||||
listenForContract,
|
||||
listenForContracts,
|
||||
listenForHotContracts,
|
||||
listenForInactiveContracts,
|
||||
|
@ -62,39 +60,6 @@ export const useHotContracts = () => {
|
|||
return hotContracts
|
||||
}
|
||||
|
||||
export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
|
||||
const [__, triggerUpdate] = useState(0)
|
||||
const contractDict = useRef<{ [id: string]: Contract }>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (contracts === undefined) return
|
||||
|
||||
contractDict.current = Object.fromEntries(contracts.map((c) => [c.id, c]))
|
||||
|
||||
const disposes = contracts.map((contract) => {
|
||||
const { id } = contract
|
||||
|
||||
return listenForContract(id, (contract) => {
|
||||
const curr = contractDict.current[id]
|
||||
if (!isEqual(curr, contract)) {
|
||||
contractDict.current[id] = contract as Contract
|
||||
triggerUpdate((n) => n + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
triggerUpdate((n) => n + 1)
|
||||
|
||||
return () => {
|
||||
disposes.forEach((dispose) => dispose())
|
||||
}
|
||||
}, [!!contracts])
|
||||
|
||||
return contracts && Object.keys(contractDict.current).length > 0
|
||||
? contracts.map((c) => contractDict.current[c.id])
|
||||
: undefined
|
||||
}
|
||||
|
||||
export const usePrefetchUserBetContracts = (userId: string) => {
|
||||
const queryClient = useQueryClient()
|
||||
return queryClient.prefetchQuery(
|
||||
|
|
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)
|
||||
}
|
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
|
||||
}
|
|
@ -45,6 +45,8 @@ import {
|
|||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
import { Title } from 'web/components/title'
|
||||
import { usePrefetch } from 'web/hooks/use-prefetch'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -110,19 +112,28 @@ export default function ContractPage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
// requires an admin to resolve a week after market closes
|
||||
export function needsAdminToResolve(contract: Contract) {
|
||||
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
|
||||
}
|
||||
|
||||
export function ContractPageSidebar(props: {
|
||||
user: User | null | undefined
|
||||
contract: Contract
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const { creatorId, isResolved, outcomeType } = contract
|
||||
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const allowTrade = tradingAllowed(contract)
|
||||
const allowResolve = !isResolved && isCreator && !!user
|
||||
const isAdmin = useAdmin()
|
||||
const allowResolve =
|
||||
!isResolved &&
|
||||
(isCreator || (needsAdminToResolve(contract) && isAdmin)) &&
|
||||
!!user
|
||||
|
||||
const hasSidePanel =
|
||||
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
|
||||
|
||||
|
@ -139,9 +150,19 @@ export function ContractPageSidebar(props: {
|
|||
))}
|
||||
{allowResolve &&
|
||||
(isNumeric || isPseudoNumeric ? (
|
||||
<NumericResolutionPanel creator={user} contract={contract} />
|
||||
<NumericResolutionPanel
|
||||
isAdmin={isAdmin}
|
||||
creator={user}
|
||||
isCreator={isCreator}
|
||||
contract={contract}
|
||||
/>
|
||||
) : (
|
||||
<ResolutionPanel creator={user} contract={contract} />
|
||||
<ResolutionPanel
|
||||
isAdmin={isAdmin}
|
||||
creator={user}
|
||||
isCreator={isCreator}
|
||||
contract={contract}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
) : null
|
||||
|
@ -154,10 +175,8 @@ export function ContractPageContent(
|
|||
}
|
||||
) {
|
||||
const { backToHome, comments, user } = props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
usePrefetch(user?.id)
|
||||
|
||||
useTracking(
|
||||
'view market',
|
||||
{
|
||||
|
|
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.' })
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import {
|
|||
usePersistentState,
|
||||
urlParamStore,
|
||||
} from 'web/hooks/use-persistent-state'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
const MAX_CONTRACTS_RENDERED = 100
|
||||
|
||||
|
@ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: {
|
|||
>
|
||||
<option value="score">Trending</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="most-traded">Most traded</option>
|
||||
<option value="most-traded">Most ${PAST_BETS}</option>
|
||||
<option value="24-hour-vol">24h volume</option>
|
||||
<option value="close-date">Closing soon</option>
|
||||
</select>
|
||||
|
|
|
@ -426,7 +426,7 @@ export function NewContract(props: {
|
|||
<div className="form-control mb-1 items-start">
|
||||
<label className="label mb-1 gap-2">
|
||||
<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>
|
||||
<Row className={'w-full items-center gap-2'}>
|
||||
<ChoicesToggleGroup
|
||||
|
@ -483,7 +483,7 @@ export function NewContract(props: {
|
|||
<label className="label mb-1 gap-2">
|
||||
<span>Cost</span>
|
||||
<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>
|
||||
{!deservesFreeMarket ? (
|
||||
|
|
|
@ -116,7 +116,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
tradingAllowed(contract) &&
|
||||
!betPanelOpen && (
|
||||
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
|
||||
Bet
|
||||
Predict
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ export default function Home() {
|
|||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||
<Row className={'w-full items-center justify-between'}>
|
||||
<Title text="Edit your home page" />
|
||||
<Title text="Customize your home page" />
|
||||
<DoneButton />
|
||||
</Row>
|
||||
|
||||
|
@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) {
|
|||
|
||||
return (
|
||||
<SiteLink href="/experimental/home">
|
||||
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
|
||||
<Button
|
||||
size="lg"
|
||||
color="blue"
|
||||
className={clsx(className, 'flex whitespace-nowrap')}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</SiteLink>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import Router from 'next/router'
|
||||
import {
|
||||
PencilIcon,
|
||||
AdjustmentsIcon,
|
||||
PlusSmIcon,
|
||||
ArrowSmRightIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
|
@ -26,11 +26,12 @@ import { Row } from 'web/components/layout/row'
|
|||
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { ProfitBadge } from 'web/components/bets-list'
|
||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
|
||||
const Home = () => {
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
||||
useTracking('view home')
|
||||
|
@ -44,14 +45,14 @@ const Home = () => {
|
|||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-12">
|
||||
<Row className={'w-full items-center justify-between'}>
|
||||
<Title className="!mb-0" text="Home" />
|
||||
|
||||
<EditButton />
|
||||
<Row className={'mt-4 w-full items-start justify-between'}>
|
||||
<Row className="items-end gap-4">
|
||||
<Title className="!mb-1 !mt-0" text="Home" />
|
||||
<EditButton />
|
||||
</Row>
|
||||
<DailyProfitAndBalance className="" user={user} />
|
||||
</Row>
|
||||
|
||||
<DailyProfitAndBalance userId={user?.id} />
|
||||
|
||||
{sections.map((item) => {
|
||||
const { id } = item
|
||||
if (id === 'daily-movers') {
|
||||
|
@ -97,17 +98,10 @@ function SearchSection(props: {
|
|||
followed?: boolean
|
||||
}) {
|
||||
const { label, user, sort, yourBets, followed } = props
|
||||
const href = `/home?s=${sort}`
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<SiteLink className="mb-2 text-xl" href={href}>
|
||||
{label}{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<SectionHeader label={label} href={`/home?s=${sort}`} />
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={sort}
|
||||
|
@ -135,13 +129,7 @@ function GroupSection(props: {
|
|||
|
||||
return (
|
||||
<Col>
|
||||
<SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
|
||||
{group.name}{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<SectionHeader label={group.name} href={groupPath(group.slug)} />
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={'score'}
|
||||
|
@ -161,15 +149,25 @@ function DailyMoversSection(props: { userId: string | null | undefined }) {
|
|||
|
||||
return (
|
||||
<Col className="gap-2">
|
||||
<SiteLink className="text-xl" href={'/daily-movers'}>
|
||||
Daily movers{' '}
|
||||
<SectionHeader label="Daily movers" href="daily-movers" />
|
||||
<ProbChangeTable changes={changes} />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader(props: { label: string; href: string }) {
|
||||
const { label, href } = props
|
||||
|
||||
return (
|
||||
<Row className="mb-3 items-center justify-between">
|
||||
<SiteLink className="text-xl" href={href}>
|
||||
{label}{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<ProbChangeTable changes={changes} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -178,45 +176,42 @@ function EditButton(props: { className?: string }) {
|
|||
|
||||
return (
|
||||
<SiteLink href="/experimental/home/edit">
|
||||
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
|
||||
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
|
||||
Edit
|
||||
<Button size="sm" color="gray-white" className={clsx(className, 'flex')}>
|
||||
<AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" />
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyProfitAndBalance(props: {
|
||||
userId: string | null | undefined
|
||||
user: User | null | undefined
|
||||
className?: string
|
||||
}) {
|
||||
const { userId, className } = props
|
||||
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
|
||||
const { user, className } = props
|
||||
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
|
||||
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
|
||||
|
||||
if (first === undefined || last === undefined) return null
|
||||
|
||||
const profit =
|
||||
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
|
||||
|
||||
const balanceChange = last.balance - first.balance
|
||||
const profitPercent = profit / first.investmentValue
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'text-lg')}>
|
||||
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
|
||||
{profit >= 0 && '+'}
|
||||
{formatMoney(profit)}
|
||||
</span>{' '}
|
||||
profit and{' '}
|
||||
<span
|
||||
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
|
||||
>
|
||||
{balanceChange >= 0 && '+'}
|
||||
{formatMoney(balanceChange)}
|
||||
</span>{' '}
|
||||
balance today
|
||||
</div>
|
||||
<Row className={'gap-4'}>
|
||||
<Col>
|
||||
<div className="text-gray-500">Daily profit</div>
|
||||
<Row className={clsx(className, 'items-center text-lg')}>
|
||||
<span>{formatMoney(profit)}</span>{' '}
|
||||
<ProfitBadge profitPercent={profitPercent * 100} />
|
||||
</Row>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="text-gray-500">Streak</div>
|
||||
<Row className={clsx(className, 'items-center text-lg')}>
|
||||
<span>🔥 {user?.currentBettingStreak ?? 0}</span>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
|
|
@ -29,8 +29,7 @@ import Custom404 from '../../404'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { ContractSearch } from 'web/components/contract-search'
|
||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||
|
@ -52,6 +51,8 @@ import { track } from '@amplitude/analytics-browser'
|
|||
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/solid'
|
||||
import { GroupSidebar } from 'web/components/nav/group-sidebar'
|
||||
import { SelectMarketsModal } from 'web/components/contract-select-modal'
|
||||
import { BETTORS } from 'common/user'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -81,12 +82,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
const topCreators = await toTopUsers(cachedTopCreatorIds)
|
||||
|
||||
const creator = await creatorPromise
|
||||
// Only count unresolved markets
|
||||
const contractsCount = contracts.filter((c) => !c.isResolved).length
|
||||
|
||||
return {
|
||||
props: {
|
||||
contractsCount,
|
||||
group,
|
||||
memberIds,
|
||||
creator,
|
||||
|
@ -112,7 +110,6 @@ const groupSubpages = [
|
|||
] as const
|
||||
|
||||
export default function GroupPage(props: {
|
||||
contractsCount: number
|
||||
group: Group | null
|
||||
memberIds: string[]
|
||||
creator: User
|
||||
|
@ -123,7 +120,6 @@ export default function GroupPage(props: {
|
|||
suggestedFilter: 'open' | 'all'
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
contractsCount: 0,
|
||||
group: null,
|
||||
memberIds: [],
|
||||
creator: null,
|
||||
|
@ -132,8 +128,7 @@ export default function GroupPage(props: {
|
|||
messages: [],
|
||||
suggestedFilter: 'open',
|
||||
}
|
||||
const { contractsCount, creator, topTraders, topCreators, suggestedFilter } =
|
||||
props
|
||||
const { creator, topTraders, topCreators, suggestedFilter } = props
|
||||
|
||||
const router = useRouter()
|
||||
const { slugs } = router.query as { slugs: string[] }
|
||||
|
@ -164,7 +159,7 @@ export default function GroupPage(props: {
|
|||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||
<GroupLeaderboard
|
||||
topUsers={topTraders}
|
||||
title="🏅 Top traders"
|
||||
title={`🏅 Top ${BETTORS}`}
|
||||
header="Profit"
|
||||
maxToShow={maxLeaderboardSize}
|
||||
/>
|
||||
|
@ -219,7 +214,6 @@ export default function GroupPage(props: {
|
|||
|
||||
const sidebarPages = [
|
||||
{
|
||||
badge: `${contractsCount}`,
|
||||
title: 'Markets',
|
||||
content: questionsPage,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
|
@ -452,27 +446,12 @@ function GroupLeaderboard(props: {
|
|||
function AddContractButton(props: { group: Group; user: User }) {
|
||||
const { group, user } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const groupContractIds = useGroupContractIds(group.id)
|
||||
|
||||
async function addContractToCurrentGroup(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() {
|
||||
Promise.all(
|
||||
contracts.map(async (contract) => {
|
||||
setLoading(true)
|
||||
await addContractToGroup(group, contract, user.id)
|
||||
})
|
||||
).then(() => {
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
setContracts([])
|
||||
})
|
||||
async function onSubmit(contracts: Contract[]) {
|
||||
await Promise.all(
|
||||
contracts.map((contract) => addContractToGroup(group, contract, user.id))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -488,72 +467,27 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
<SelectMarketsModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className={'max-w-4xl sm:p-0'}
|
||||
size={'xl'}
|
||||
>
|
||||
<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'}>
|
||||
Add pre-existing markets to this group, or{' '}
|
||||
<Link href={`/create?groupId=${group.id}`}>
|
||||
<span className="cursor-pointer font-semibold underline">
|
||||
create a new one
|
||||
</span>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<Col className={'w-full '}>
|
||||
{!loading ? (
|
||||
<Row className={'justify-end gap-4'}>
|
||||
<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}
|
||||
headerClassName="md:sticky"
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
title="Add markets"
|
||||
description={
|
||||
<div className={'text-md my-4 text-gray-600'}>
|
||||
Add pre-existing markets to this group, or{' '}
|
||||
<Link href={`/create?groupId=${group.id}`}>
|
||||
<span className="cursor-pointer font-semibold underline">
|
||||
create a new one
|
||||
</span>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</Col>
|
||||
</Modal>
|
||||
}
|
||||
submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`}
|
||||
onSubmit={onSubmit}
|
||||
contractSearchOptions={{
|
||||
additionalFilter: { excludeContractIds: groupContractIds },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Title } from 'web/components/title'
|
|||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { BETTORS } from 'common/user'
|
||||
|
||||
export async function getStaticProps() {
|
||||
const props = await fetchProps()
|
||||
|
@ -79,7 +80,7 @@ export default function Leaderboards(_props: {
|
|||
<>
|
||||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title={`🏅 Top ${BETTORS}`}
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
|
@ -126,7 +127,7 @@ export default function Leaderboards(_props: {
|
|||
<Page>
|
||||
<SEO
|
||||
title="Leaderboards"
|
||||
description="Manifold's leaderboards show the top traders and market creators."
|
||||
description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`}
|
||||
url="/leaderboards"
|
||||
/>
|
||||
<Title text={'Leaderboards'} className={'hidden md:block'} />
|
||||
|
|
|
@ -112,6 +112,7 @@ export default function Notifications() {
|
|||
content: (
|
||||
<NotificationSettings
|
||||
navigateToSection={navigateToSection}
|
||||
privateUser={privateUser}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -297,7 +298,7 @@ function IncomeNotificationGroupItem(props: {
|
|||
...notificationsForSourceTitle[0],
|
||||
sourceText: sum.toString(),
|
||||
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
|
||||
data: JSON.stringify(uniqueUsers),
|
||||
data: { uniqueUsers },
|
||||
}
|
||||
newNotifications.push(newNotification)
|
||||
}
|
||||
|
@ -414,7 +415,7 @@ function IncomeNotificationItem(props: {
|
|||
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
|
||||
const isUniqueBettorBonus = sourceType === 'bonus'
|
||||
const userLinks: MultiUserLinkInfo[] =
|
||||
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : []
|
||||
isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : []
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
|
@ -428,7 +429,7 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} new traders on`
|
||||
} new predictors on`
|
||||
: 'bonus on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
|
@ -436,19 +437,22 @@ function IncomeNotificationItem(props: {
|
|||
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
|
||||
else reasonText = 'for your'
|
||||
} 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
|
||||
} else if (sourceType === 'tip_and_like' && sourceText) {
|
||||
reasonText = !simple ? `liked` : `in likes on`
|
||||
}
|
||||
|
||||
const streakInDays =
|
||||
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
||||
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
||||
: user?.currentBettingStreak ?? 0
|
||||
const streakInDays = notification.data?.streak
|
||||
? notification.data?.streak
|
||||
: Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
||||
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
||||
: user?.currentBettingStreak ?? 0
|
||||
const bettingStreakText =
|
||||
sourceType === 'betting_streak_bonus' &&
|
||||
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
|
||||
(sourceText
|
||||
? `🔥 ${streakInDays} day Prediction Streak`
|
||||
: 'Prediction Streak')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -546,7 +550,7 @@ function IncomeNotificationItem(props: {
|
|||
{(isTip || isUniqueBettorBonus) && (
|
||||
<MultiUserTransactionLink
|
||||
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'}>
|
||||
|
@ -1031,52 +1035,53 @@ function getReasonForShowingNotification(
|
|||
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
||||
let reasonText: string
|
||||
// TODO: we could leave out this switch and just use the reason field now that they have more information
|
||||
switch (sourceType) {
|
||||
case 'comment':
|
||||
if (reason === 'reply_to_users_answer')
|
||||
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')
|
||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||
else reasonText = justSummary ? `commented` : `commented on`
|
||||
break
|
||||
case 'contract':
|
||||
if (reason === 'contract_from_followed_user')
|
||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||
else if (sourceUpdateType === 'resolved')
|
||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
|
||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||
break
|
||||
case 'answer':
|
||||
if (reason === 'answer_on_your_contract')
|
||||
reasonText = `answered your question `
|
||||
else reasonText = `answered`
|
||||
break
|
||||
case 'follow':
|
||||
reasonText = 'followed you'
|
||||
break
|
||||
case 'liquidity':
|
||||
reasonText = 'added a subsidy to your question'
|
||||
break
|
||||
case 'group':
|
||||
reasonText = 'added you to the group'
|
||||
break
|
||||
case 'user':
|
||||
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
|
||||
reasonText = 'joined to bet on your market'
|
||||
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||
else reasonText = 'joined because of you'
|
||||
break
|
||||
case 'bet':
|
||||
reasonText = 'bet against you'
|
||||
break
|
||||
case 'challenge':
|
||||
reasonText = 'accepted your challenge'
|
||||
break
|
||||
default:
|
||||
reasonText = ''
|
||||
}
|
||||
if (reason === 'tagged_user')
|
||||
reasonText = justSummary ? 'tagged you' : 'tagged you on'
|
||||
else
|
||||
switch (sourceType) {
|
||||
case 'comment':
|
||||
if (reason === 'reply_to_users_answer')
|
||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||
else if (reason === 'reply_to_users_comment')
|
||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||
else reasonText = justSummary ? `commented` : `commented on`
|
||||
break
|
||||
case 'contract':
|
||||
if (reason === 'contract_from_followed_user')
|
||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||
else if (sourceUpdateType === 'resolved')
|
||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
|
||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||
break
|
||||
case 'answer':
|
||||
if (reason === 'answer_on_your_contract')
|
||||
reasonText = `answered your question `
|
||||
else reasonText = `answered`
|
||||
break
|
||||
case 'follow':
|
||||
reasonText = 'followed you'
|
||||
break
|
||||
case 'liquidity':
|
||||
reasonText = 'added a subsidy to your question'
|
||||
break
|
||||
case 'group':
|
||||
reasonText = 'added you to the group'
|
||||
break
|
||||
case 'user':
|
||||
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
|
||||
reasonText = 'joined to bet on your market'
|
||||
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||
else reasonText = 'joined because of you'
|
||||
break
|
||||
case 'bet':
|
||||
reasonText = 'bet against you'
|
||||
break
|
||||
case 'challenge':
|
||||
reasonText = 'accepted your challenge'
|
||||
break
|
||||
default:
|
||||
reasonText = ''
|
||||
}
|
||||
return reasonText
|
||||
}
|
||||
|
|
|
@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { User, PrivateUser } from 'common/user'
|
||||
import {
|
||||
getUserAndPrivateUser,
|
||||
updateUser,
|
||||
updatePrivateUser,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
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) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||
|
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
|
|||
}
|
||||
|
||||
const updateApiKey = async (e: React.MouseEvent) => {
|
||||
const newApiKey = crypto.randomUUID()
|
||||
setApiKey(newApiKey)
|
||||
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
|
||||
setApiKey(privateUser.apiKey || '')
|
||||
})
|
||||
const newApiKey = await generateNewApiKey(user.id)
|
||||
setApiKey(newApiKey ?? '')
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
|
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwitchPanel />
|
||||
</Col>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { SiteLink } from 'web/components/site-link'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import { getStats } from 'web/lib/firebase/stats'
|
||||
import { Stats } from 'common/stats'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
export default function Analytics() {
|
||||
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
||||
|
@ -156,7 +157,7 @@ export function CustomAnalytics(props: {
|
|||
defaultIndex={0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Trades',
|
||||
title: PAST_BETS,
|
||||
content: (
|
||||
<DailyCountChart
|
||||
dailyCounts={dailyBetCounts}
|
||||
|
|
|
@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
|||
<Page>
|
||||
<SEO
|
||||
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%]">
|
||||
{sections.map(
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user