diff --git a/common/bet.ts b/common/bet.ts index 3d9d6a5a..8afebcd8 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -61,5 +61,3 @@ export type fill = { // I.e. -fill.shares === matchedBet.shares isSale?: boolean } - -export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/loans.ts b/common/loans.ts new file mode 100644 index 00000000..64742b3e --- /dev/null +++ b/common/loans.ts @@ -0,0 +1,138 @@ +import { Dictionary, groupBy, sumBy, minBy } from 'lodash' +import { Bet } from './bet' +import { getContractBetMetrics } from './calculate' +import { + Contract, + CPMMContract, + FreeResponseContract, + MultipleChoiceContract, +} from './contract' +import { PortfolioMetrics, User } from './user' +import { filterDefined } from './util/array' + +const LOAN_WEEKLY_RATE = 0.05 + +const calculateNewLoan = (investedValue: number, loanTotal: number) => { + const netValue = investedValue - loanTotal + return netValue * LOAN_WEEKLY_RATE +} + +export const getLoanUpdates = ( + users: User[], + contractsById: { [contractId: string]: Contract }, + portfolioByUser: { [userId: string]: PortfolioMetrics | undefined }, + betsByUser: { [userId: string]: Bet[] } +) => { + const eligibleUsers = filterDefined( + users.map((user) => + isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined + ) + ) + + const betUpdates = eligibleUsers + .map((user) => { + const updates = calculateLoanBetUpdates( + betsByUser[user.id] ?? [], + contractsById + ).betUpdates + return updates.map((update) => ({ ...update, user })) + }) + .flat() + + const updatesByUser = groupBy(betUpdates, (update) => update.userId) + const userPayouts = Object.values(updatesByUser).map((updates) => { + return { + user: updates[0].user, + payout: sumBy(updates, (update) => update.newLoan), + } + }) + + return { + betUpdates, + userPayouts, + } +} + +const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => { + if (!portfolio) return true + + const { balance, investmentValue } = portfolio + return balance + investmentValue > 0 +} + +const calculateLoanBetUpdates = ( + bets: Bet[], + contractsById: Dictionary +) => { + const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contracts = filterDefined( + Object.keys(betsByContract).map((contractId) => contractsById[contractId]) + ).filter((c) => !c.isResolved) + + const betUpdates = filterDefined( + contracts + .map((c) => { + if (c.mechanism === 'cpmm-1') { + return getBinaryContractLoanUpdate(c, betsByContract[c.id]) + } else if ( + c.outcomeType === 'FREE_RESPONSE' || + c.outcomeType === 'MULTIPLE_CHOICE' + ) + return getFreeResponseContractLoanUpdate(c, betsByContract[c.id]) + else { + // Unsupported contract / mechanism for loans. + return [] + } + }) + .flat() + ) + + const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal) + + return { + totalNewLoan, + betUpdates, + } +} + +const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => { + const { invested } = getContractBetMetrics(contract, bets) + const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const oldestBet = minBy(bets, (bet) => bet.createdTime) + + const newLoan = calculateNewLoan(invested, loanAmount) + if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined + + const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan + + return { + userId: oldestBet.userId, + contractId: contract.id, + betId: oldestBet.id, + newLoan, + loanTotal, + } +} + +const getFreeResponseContractLoanUpdate = ( + contract: FreeResponseContract | MultipleChoiceContract, + bets: Bet[] +) => { + const openBets = bets.filter((bet) => bet.isSold || bet.sale) + + return openBets.map((bet) => { + const loanAmount = bet.loanAmount ?? 0 + const newLoan = calculateNewLoan(bet.amount, loanAmount) + const loanTotal = loanAmount + newLoan + + if (isNaN(newLoan) || newLoan <= 0) return undefined + + return { + userId: bet.userId, + contractId: contract.id, + betId: bet.id, + newLoan, + loanTotal, + } + }) +} diff --git a/common/new-bet.ts b/common/new-bet.ts index 576f35f8..7085a4fe 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,6 +1,6 @@ import { sortBy, sum, sumBy } from 'lodash' -import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' +import { Bet, fill, LimitBet, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, @@ -276,8 +276,7 @@ export const getBinaryBetStats = ( export const getNewBinaryDpmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: DPMBinaryContract, - loanAmount: number + contract: DPMBinaryContract ) => { const { YES: yesPool, NO: noPool } = contract.pool @@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = ( const newBet: CandidateBet = { contractId: contract.id, amount, - loanAmount, + loanAmount: 0, shares, outcome, probBefore, @@ -324,7 +323,6 @@ export const getNewMultiBetInfo = ( outcome: string, amount: number, contract: FreeResponseContract | MultipleChoiceContract, - loanAmount: number ) => { const { pool, totalShares, totalBets } = contract @@ -345,7 +343,7 @@ export const getNewMultiBetInfo = ( const newBet: CandidateBet = { contractId: contract.id, amount, - loanAmount, + loanAmount: 0, shares, outcome, probBefore, @@ -399,13 +397,3 @@ export const getNumericBetsInfo = ( return { newBet, newPool, newTotalShares, newTotalBets } } - -export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { - const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale) - const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0) - const loanAmount = Math.min( - newBetAmount, - MAX_LOAN_PER_CONTRACT - prevLoanAmount - ) - return loanAmount -} diff --git a/common/notification.ts b/common/notification.ts index 99f9d852..0a69f89d 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -39,6 +39,7 @@ export type notification_source_types = | 'bonus' | 'challenge' | 'betting_streak_bonus' + | 'loan' export type notification_source_update_types = | 'created' @@ -68,3 +69,4 @@ export type notification_reason_types = | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' + | 'loan_income' diff --git a/common/redeem.ts b/common/redeem.ts index 4a4080f6..e0839ff8 100644 --- a/common/redeem.ts +++ b/common/redeem.ts @@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => { const yesShares = sumBy(yesBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares) const shares = Math.max(Math.min(yesShares, noShares), 0) + const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0 const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPayment = Math.min(loanAmount, shares) + const loanPayment = loanAmount * soldFrac const netAmount = shares - loanPayment return { shares, loanPayment, netAmount } } diff --git a/common/sell-bet.ts b/common/sell-bet.ts index e1fd9c5d..bc8fe596 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -13,7 +13,7 @@ export type CandidateBet = Omit export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { const { pool, totalShares, totalBets } = contract - const { id: betId, amount, shares, outcome } = bet + const { id: betId, amount, shares, outcome, loanAmount } = bet const adjShareValue = calculateDpmShareValue(contract, bet) @@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { betId, }, fees, + loanAmount: -(loanAmount ?? 0), } return { @@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = ( shares: number, outcome: 'YES' | 'NO', contract: CPMMContract, - prevLoanAmount: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + loanPaid: number ) => { const { pool, p } = contract @@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = ( unfilledBets ) - const loanPaid = Math.min(prevLoanAmount, saleValue) const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) diff --git a/common/user.ts b/common/user.ts index 2910c54e..4b21fe2d 100644 --- a/common/user.ts +++ b/common/user.ts @@ -32,6 +32,7 @@ export type User = { allTime: number } + nextLoanCached: number followerCountCached: number followedCategories?: string[] diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 2abaf44d..0b8b4e7a 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { } transaction.create(newAnswerDoc, answer) - const loanAmount = 0 - const { newBet, newPool, newTotalShares, newTotalBets } = - getNewMultiBetInfo(answerId, amount, contract, loanAmount) + getNewMultiBetInfo(answerId, amount, contract) const newBalance = user.balance - amount const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 90250e73..3fb1f9c3 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -471,6 +471,32 @@ export const createReferralNotification = async ( await notificationRef.set(removeUndefinedProps(notification)) } +export const createLoanIncomeNotification = async ( + toUser: User, + idempotencyKey: string, + income: number +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'loan_income', + createdTime: Date.now(), + isSeen: false, + sourceId: idempotencyKey, + sourceType: 'loan', + sourceUpdateType: 'updated', + sourceUserName: toUser.name, + sourceUserUsername: toUser.username, + sourceUserAvatarUrl: toUser.avatarUrl, + sourceText: income.toString(), + sourceTitle: 'Loan', + } + await notificationRef.set(removeUndefinedProps(notification)) +} + const groupPath = (groupSlug: string) => `/group/${groupSlug}` export const createChallengeAcceptedNotification = async ( diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 7156855e..f42fb5c3 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -75,6 +75,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { createdTime: Date.now(), profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, + nextLoanCached: 0, followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, diff --git a/functions/src/index.ts b/functions/src/index.ts index 4d7cf42b..b0ad50fa 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract' export * from './on-view' export * from './update-metrics' export * from './update-stats' +export * from './update-loans' export * from './backup-db' export * from './market-close-notifications' export * from './on-create-answer' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 780b50d6..44a96210 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -59,7 +59,6 @@ export const placebet = newEndpoint({}, async (req, auth) => { const user = userSnap.data() as User if (user.balance < amount) throw new APIError(400, 'Insufficient balance.') - const loanAmount = 0 const { closeTime, outcomeType, mechanism, collectedFees, volume } = contract if (closeTime && Date.now() > closeTime) @@ -119,7 +118,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { const answerDoc = contractDoc.collection('answers').doc(outcome) const answerSnap = await trans.get(answerDoc) if (!answerSnap.exists) throw new APIError(400, 'Invalid answer') - return getNewMultiBetInfo(outcome, amount, contract, loanAmount) + return getNewMultiBetInfo(outcome, amount, contract) } else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') { const { outcome, value } = validate(numericSchema, req.body) return getNumericBetsInfo(value, outcome, amount, contract) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 18df4536..22dc3f12 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ const saleAmount = newBet.sale!.amount - const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0) + const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0) const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() transaction.update(userDoc, { balance: newBalance }) transaction.update(betDoc, { isSold: true }) + // Note: id should have been newBetDoc.id! But leaving it for now so it's consistent. transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet }) transaction.update( contractDoc, diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index ec08ab86..d9f99de3 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -7,7 +7,7 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { getValues, log } from './utils' +import { log } from './utils' import { Bet } from '../../common/bet' import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' @@ -28,12 +28,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [[contractSnap, userSnap], userBets] = await Promise.all([ - transaction.getAll(contractDoc, userDoc), - getValues(betsQ), // TODO: why is this not in the transaction?? - ]) + const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = + await Promise.all([ + transaction.getAll(contractDoc, userDoc), + transaction.get(betsQ), + transaction.get(getUnfilledBetsQuery(contractDoc)), + ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') + const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) const contract = contractSnap.data() as Contract const user = userSnap.data() as User @@ -45,7 +49,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { if (closeTime && Date.now() > closeTime) throw new APIError(400, 'Trading is closed.') - const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) const sharesByOutcome = mapValues(betsByOutcome, (bets) => sumBy(bets, (b) => b.shares) @@ -77,18 +81,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => { throw new APIError(400, `You can only sell up to ${maxShares} shares.`) const soldShares = Math.min(sharesToSell, maxShares) - - const unfilledBetsSnap = await transaction.get( - getUnfilledBetsQuery(contractDoc) - ) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + const saleFrac = soldShares / maxShares + let loanPaid = saleFrac * loanAmount + if (!isFinite(loanPaid)) loanPaid = 0 const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( soldShares, chosenOutcome, contract, - prevLoanAmount, - unfilledBets + unfilledBets, + loanPaid ) if ( @@ -104,7 +106,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { updateMakers(makers, newBetDoc.id, contractDoc, transaction) transaction.update(userDoc, { - balance: FieldValue.increment(-newBet.amount), + balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)), }) transaction.create(newBetDoc, { id: newBetDoc.id, diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts new file mode 100644 index 00000000..fd89b643 --- /dev/null +++ b/functions/src/update-loans.ts @@ -0,0 +1,88 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { groupBy, keyBy } from 'lodash' +import { getValues, log, payUser, writeAsync } from './utils' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { PortfolioMetrics, User } from '../../common/user' +import { getLoanUpdates } from '../../common/loans' +import { createLoanIncomeNotification } from './create-notification' + +const firestore = admin.firestore() + +export const updateLoans = functions + .runWith({ memory: '2GB', timeoutSeconds: 540 }) + // Run every Monday. + .pubsub.schedule('0 0 * * 1') + .timeZone('America/Los_Angeles') + .onRun(updateLoansCore) + +async function updateLoansCore() { + log('Updating loans...') + + const [users, contracts, bets] = await Promise.all([ + getValues(firestore.collection('users')), + getValues( + firestore.collection('contracts').where('isResolved', '==', false) + ), + getValues(firestore.collectionGroup('bets')), + ]) + log( + `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` + ) + const userPortfolios = await Promise.all( + users.map(async (user) => { + const portfolio = await getValues( + firestore + .collection(`users/${user.id}/portfolioHistory`) + .orderBy('timestamp', 'desc') + .limit(1) + ) + return portfolio[0] + }) + ) + log(`Loaded ${userPortfolios.length} portfolios`) + const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId) + + const contractsById = Object.fromEntries( + contracts.map((contract) => [contract.id, contract]) + ) + const betsByUser = groupBy(bets, (bet) => bet.userId) + const { betUpdates, userPayouts } = getLoanUpdates( + users, + contractsById, + portfolioByUser, + betsByUser + ) + + log(`${betUpdates.length} bet updates.`) + + const betDocUpdates = betUpdates.map((update) => ({ + doc: firestore + .collection('contracts') + .doc(update.contractId) + .collection('bets') + .doc(update.betId), + fields: { + loanAmount: update.loanTotal, + }, + })) + + await writeAsync(firestore, betDocUpdates) + + log(`${userPayouts.length} user payouts`) + + await Promise.all( + userPayouts.map(({ user, payout }) => payUser(user.id, payout)) + ) + + const today = new Date().toDateString().replace(' ', '-') + const key = `loan-notifications-${today}` + await Promise.all( + userPayouts.map(({ user, payout }) => + createLoanIncomeNotification(user, key, payout) + ) + ) + + log('Notifications sent!') +} diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index cc9f8ebe..a2e72053 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' @@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user' import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' import { last } from 'lodash' +import { getLoanUpdates } from '../../common/loans' const firestore = admin.firestore() @@ -21,7 +22,9 @@ const computeInvestmentValue = ( if (bet.sale || bet.isSold) return 0 const payout = calculatePayout(contract, bet, 'MKT') - return payout - (bet.loanAmount ?? 0) + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value }) } @@ -71,7 +74,8 @@ export const updateMetricsCore = async () => { const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const betsByUser = groupBy(bets, (bet) => bet.userId) const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId) - const userUpdates = users.map((user) => { + + const userMetrics = users.map((user) => { const currentBets = betsByUser[user.id] ?? [] const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? [] @@ -93,32 +97,56 @@ export const updateMetricsCore = async () => { newPortfolio, didProfitChange ) - return { - fieldUpdates: { - doc: firestore.collection('users').doc(user.id), - fields: { - creatorVolumeCached: newCreatorVolume, - ...(didProfitChange && { - profitCached: newProfit, - }), - }, - }, - - subcollectionUpdates: { - doc: firestore - .collection('users') - .doc(user.id) - .collection('portfolioHistory') - .doc(), - fields: { - ...(didProfitChange && { - ...newPortfolio, - }), - }, - }, + user, + newCreatorVolume, + newPortfolio, + newProfit, + didProfitChange, } }) + + const portfolioByUser = Object.fromEntries( + userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio]) + ) + const { userPayouts } = getLoanUpdates( + users, + contractsById, + portfolioByUser, + betsByUser + ) + const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) + + const userUpdates = userMetrics.map( + ({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { + const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 + return { + fieldUpdates: { + doc: firestore.collection('users').doc(user.id), + fields: { + creatorVolumeCached: newCreatorVolume, + ...(didProfitChange && { + profitCached: newProfit, + }), + nextLoanCached, + }, + }, + + subcollectionUpdates: { + doc: firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .doc(), + fields: { + ...(didProfitChange && { + ...newPortfolio, + }), + }, + }, + } + } + ) await writeAsync( firestore, userUpdates.map((u) => u.fieldUpdates) @@ -234,6 +262,6 @@ const calculateNewProfit = ( } export const updateMetrics = functions - .runWith({ memory: '1GB', timeoutSeconds: 540 }) + .runWith({ memory: '2GB', timeoutSeconds: 540 }) .pubsub.schedule('every 15 minutes') .onRun(updateMetricsCore) diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index eb90f6d9..732292f1 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -16,7 +16,7 @@ export function BettingStreakModal(props: { 🔥 - Daily betting streaks + Daily betting streaks • What are they? diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx new file mode 100644 index 00000000..c8d30b4e --- /dev/null +++ b/web/components/profile/loans-modal.tsx @@ -0,0 +1,47 @@ +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' + +export function LoansModal(props: { + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { isOpen, setOpen } = props + + return ( + + + 🏦 + Loans on your bets + + • What are loans? + + Every Monday, get 5% of your total bet amount back as a loan. + + + • Do I have to pay back a loan? + + + Yes, don't worry! You will automatically pay back loans when the + market resolves or you sell your bet. + + + • What is the purpose of loans? + + + Loans make it worthwhile to bet on markets that won't resolve for + months or years, because your investment won't be locked up as long. + + • What is an example? + + For example, if you bet M$100 on "Will I become a millionare?" on + Sunday, you will get M$5 back on Monday. + + + Previous loans count against your total bet amount. So, the next + week, you would get back 5% of M$95 = M$4.75. + + + + + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2aa837e9..dc68898f 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -29,6 +29,7 @@ import { 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 { LoansModal } from './profile/loans-modal' import { REFERRAL_AMOUNT } from 'common/user' export function UserLink(props: { @@ -68,6 +69,7 @@ export function UserPage(props: { user: User }) { const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [showConfetti, setShowConfetti] = useState(false) const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) + const [showLoansModal, setShowLoansModal] = useState(false) useEffect(() => { const claimedMana = router.query['claimed-mana'] === 'yes' @@ -75,6 +77,9 @@ export function UserPage(props: { user: User }) { setShowBettingStreakModal(showBettingStreak) setShowConfetti(claimedMana || showBettingStreak) + const showLoansModel = router.query['show'] === 'loans' + setShowLoansModal(showLoansModel) + const query = { ...router.query } if (query.claimedMana || query.show) { delete query['claimed-mana'] @@ -107,6 +112,9 @@ export function UserPage(props: { user: User }) { isOpen={showBettingStreakModal} setOpen={setShowBettingStreakModal} /> + {showLoansModal && ( + + )} {/* Banner image up top, with an circle avatar overlaid */}
{!isCurrentUser && } {isCurrentUser && ( - + {' '}
Edit
@@ -138,9 +146,14 @@ export function UserPage(props: { user: User }) { {/* Profile details: name, username, bio, and link to twitter/discord */} - + - {user.name} + + {user.name} + @{user.username} @@ -160,9 +173,20 @@ export function UserPage(props: { user: User }) { className={'cursor-pointer items-center text-gray-500'} onClick={() => setShowBettingStreakModal(true)} > - 🔥{user.currentBettingStreak ?? 0} + 🔥 {user.currentBettingStreak ?? 0} streak + setShowLoansModal(true)} + > + + 🏦 {formatMoney(user.nextLoanCached ?? 0)} + + next loan + diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 9df162bd..ecc4ce2a 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -5,7 +5,7 @@ import { getNotificationsQuery, listenForNotifications, } from 'web/lib/firebase/notifications' -import { groupBy, map } from 'lodash' +import { groupBy, map, partition } from 'lodash' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' @@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) { const notificationGroupsByDay = groupBy(notifications, (notification) => new Date(notification.createdTime).toDateString() ) + const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus'] + Object.keys(notificationGroupsByDay).forEach((day) => { const notificationsGroupedByDay = notificationGroupsByDay[day] - const incomeNotifications = notificationsGroupedByDay.filter( + const [incomeNotifications, normalNotificationsGroupedByDay] = partition( + notificationsGroupedByDay, (notification) => - notification.sourceType === 'bonus' || - notification.sourceType === 'tip' || - notification.sourceType === 'betting_streak_bonus' - ) - const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( - (notification) => - notification.sourceType !== 'bonus' && - notification.sourceType !== 'tip' && - notification.sourceType !== 'betting_streak_bonus' + incomeSourceTypes.includes(notification.sourceType ?? '') ) if (incomeNotifications.length > 0) { notificationGroups = notificationGroups.concat({ diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9541ee5b..7ec5e1ea 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -388,6 +388,8 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `tipped you on` : `in tips on` } else if (sourceType === 'betting_streak_bonus') { reasonText = 'for your' + } else if (sourceType === 'loan' && sourceText) { + reasonText = `of your invested bets returned as` } const bettingStreakText = @@ -401,7 +403,15 @@ function IncomeNotificationItem(props: { return ( <> {reasonText} - {sourceType === 'betting_streak_bonus' ? ( + {sourceType === 'loan' ? ( + simple ? ( + Loan + ) : ( + + Loan + + ) + ) : sourceType === 'betting_streak_bonus' ? ( simple ? ( {bettingStreakText} ) : ( @@ -445,6 +455,7 @@ function IncomeNotificationItem(props: { if (sourceType === 'challenge') return `${sourceSlug}` if (sourceType === 'betting_streak_bonus') return `/${sourceUserUsername}/?show=betting-streak` + if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '',