Merge branch 'main' into loans2

This commit is contained in:
James Grugett 2022-08-19 12:38:50 -05:00
commit 79a61f0e98
22 changed files with 549 additions and 151 deletions

View File

@ -38,6 +38,7 @@ export type notification_source_types =
| 'user'
| 'bonus'
| 'challenge'
| 'betting_streak_bonus'
| 'loan'
export type notification_source_update_types =
@ -67,4 +68,5 @@ export type notification_reason_types =
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'

View File

@ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
export const BETTING_STREAK_BONUS_AMOUNT = 5
export const BETTING_STREAK_RESET_HOUR = 9

View File

@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
category:
| 'CHARITY'
| 'MANALINK'
| 'TIP'
| 'REFERRAL'
| 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS'
// Any extra data
data?: { [key: string]: any }
@ -57,7 +63,7 @@ type Referral = {
type Bonus = {
fromType: 'BANK'
toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS'
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
}
export type DonationTxn = Txn & Donation

View File

@ -41,6 +41,8 @@ export type User = {
referredByGroupId?: string
lastPingTime?: number
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
}
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000

View File

@ -524,3 +524,38 @@ export const createChallengeAcceptedNotification = async (
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createBettingStreakBonusNotification = async (
user: User,
txnId: string,
bet: Bet,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: user.id,
reason: 'betting_streak_incremented',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'betting_streak_bonus',
sourceUpdateType: 'created',
sourceUserName: user.name,
sourceUserUsername: user.username,
sourceUserAvatarUrl: user.avatarUrl,
sourceText: amount.toString(),
sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`,
sourceTitle: 'Betting Streak Bonus',
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -26,6 +26,7 @@ export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
export * from './reset-betting-streaks'
// v2
export * from './health'

View File

@ -3,15 +3,20 @@ import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { getContract, getUser, getValues, isProd, log } from './utils'
import { getUser, getValues, isProd, log } from './utils'
import {
createBetFillNotification,
createBettingStreakBonusNotification,
createNotification,
} from './create-notification'
import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract'
import { runTxn, TxnData } from './transact'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from '../../common/numeric-constants'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore
.doc(contractId)
.update({ lastBetTime, lastUpdatedTime: Date.now() })
await notifyFills(bet, contractId, eventId)
await updateUniqueBettorsAndGiveCreatorBonus(
contractId,
eventId,
bet.userId
)
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId)
if (!bettor) return
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
})
const updateBettingStreak = async (
user: User,
bet: Bet,
contract: Contract,
eventId: string
) => {
const betStreakResetTime = getTodaysBettingStreakResetTime()
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
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: newBettingStreak,
})
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
100
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUserId,
fromType: 'BANK',
toId: user.id,
toType: 'USER',
amount: bonusAmount,
token: 'M$',
category: 'BETTING_STREAK_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (!result.txn) {
log("betting streak bonus txn couldn't be made")
return
}
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
bonusAmount,
eventId
)
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
contractId: string,
contract: Contract,
eventId: string,
bettorId: string
) => {
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore.collection(`contracts/${contractId}/bets`).get()
await firestore.collection(`contracts/${contract.id}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contractId}`)
log(`No bets for contract ${contract.id}`)
return
}
@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
await firestore.collection(`contracts`).doc(contractId).update({
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contractId,
contractId: contract.id,
uniqueBettorIds: newUniqueBettorIds,
}
const fromUserId = isProd()
@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
}
}
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
const notifyFills = async (
bet: Bet,
contract: Contract,
eventId: string,
user: User
) => {
if (!bet.fills) return
const user = await getUser(bet.userId)
if (!user) return
const contract = await getContract(contractId)
if (!contract) return
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
const matchedBets = (
await Promise.all(
@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
})
)
}
const getTodaysBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
}

View File

@ -0,0 +1,38 @@
// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
const firestore = admin.firestore()
export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
.onRun(async () => {
await resetBettingStreaksInternal()
})
const resetBettingStreaksInternal = async () => {
const usersSnap = await firestore.collection('users').get()
const users = usersSnap.docs.map((doc) => doc.data() as User)
for (const user of users) {
await resetBettingStreakForUser(user)
}
}
const resetBettingStreakForUser = async (user: User) => {
const betStreakResetTime = Date.now() - DAY_MS
// if they made a bet within the last day, don't reset their streak
if (
(user.lastBetTime ?? 0 > betStreakResetTime) ||
!user.currentBettingStreak ||
user.currentBettingStreak === 0
)
return
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: 0,
})
}

View File

@ -3,7 +3,6 @@ import React from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
@ -37,7 +36,7 @@ export function AmountInput(props: {
return (
<Col className={className}>
<label className="input-group">
<label className="input-group mb-4">
<span className="bg-gray-200 text-sm">{label}</span>
<input
className={clsx(
@ -57,8 +56,6 @@ export function AmountInput(props: {
/>
</label>
<Spacer h={4} />
{error && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? (
@ -115,6 +112,8 @@ export function BuyAmountInput(props: {
} else {
setError(undefined)
}
} else {
setError(undefined)
}
}

View File

@ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
// Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: {
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
btnClassName?: string

View File

@ -0,0 +1,127 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { APIError } from 'web/lib/firebase/api'
import { useEffect, useState } from 'react'
import { useMutation } from 'react-query'
import { placeBet } from 'web/lib/firebase/api'
import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
// adapted from bet-panel.ts
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
setProbAfter: (probAfter: number) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
const user = useUser()
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
const [amount, setAmount] = useState<number>()
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
const submitBet = useMutation(
() => placeBet({ outcome, amount, contractId: contract.id }),
{
onError: (e) =>
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
onSuccess: () => {
track('bet', {
location: 'embed',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount,
outcome,
isLimitOrder: false,
})
setAmount(undefined)
},
}
)
// reset error / success state on user change
useEffect(() => {
amount && submitBet.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [outcome, amount])
const tooFewFunds = error === 'Insufficient balance'
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
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>
<YesNoSelector
className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome}
onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric}
/>
<BuyAmountInput
className="-mb-4"
inputClassName={clsx(
'input-sm w-20 !text-base',
error && 'input-error'
)}
amount={amount}
onChange={setAmount}
error="" // handle error ourselves
setError={setError}
/>
{user && (
<Button
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
size="xs"
disabled={betDisabled}
onClick={() => submitBet.mutate()}
>
{submitBet.isLoading
? 'Submitting'
: submitBet.isSuccess
? 'Success!'
: 'Submit'}
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<XIcon className="ml-1 h-6 w-6" />
</button>
</Row>
{error && (
<div className="text-error my-1 text-sm">
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
</div>
)}
</Col>
)
}

View File

@ -1,20 +1,23 @@
import { ReactNode } from 'react'
import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType =
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
export function Button(props: {
className?: string
onClick?: () => void
children?: ReactNode
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
color?:
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
size?: SizeType
color?: ColorType
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {

View File

@ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: {
contract: BinaryContract
large?: boolean
className?: string
probAfter?: number // 0 to 1
}) {
const { contract, large, className } = props
const { contract, large, className, probAfter } = props
const { resolution } = contract
const textColor = `text-${getColor(contract)}`
const before = getBinaryProbPercent(contract)
const after = probAfter && formatPercent(probAfter)
const probChanged = before !== after
return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
{resolution ? (
@ -206,7 +211,14 @@ export function BinaryResolutionOrChance(props: {
</>
) : (
<>
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
{probAfter && probChanged ? (
<div>
<span className="text-gray-500 line-through">{before}</span>
<span className={textColor}>{after}</span>
</div>
) : (
<div className={textColor}>{before}</div>
)}
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
chance
</div>

View File

@ -15,7 +15,7 @@ import {
PseudoNumericResolutionOrExpectation,
} from './contract-card'
import { Bet } from 'common/bet'
import BetRow from '../bet-row'
import BetButton from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractDescription } from './contract-description'
@ -73,18 +73,18 @@ export const ContractOverview = (props: {
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<BetRow contract={contract as CPMMBinaryContract} />
<BetButton contract={contract as CPMMBinaryContract} />
)}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />}
{tradingAllowed(contract) && <BetButton contract={contract} />}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />}
{tradingAllowed(contract) && <BetButton contract={contract} />}
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||

View File

@ -19,7 +19,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { UserLink } from '../user-page'
import BetRow from '../bet-row'
import BetButton from '../bet-button'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useUser } from 'web/hooks/use-user'
@ -76,7 +76,7 @@ export function FeedItems(props: {
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetRow
<BetButton
contract={contract as CPMMBinaryContract}
className={clsx('mb-2', betRowClassName)}
/>

View File

@ -0,0 +1,32 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function BettingStreakModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen } = props
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>Betting streaks are here!</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>
You get a reward for every consecutive day that you place a bet. The
more days you bet in a row, the more you earn!
</span>
<span className={'text-indigo-700'}>
Where can I check my streak?
</span>
<span className={'ml-2'}>
You can see your current streak on the top right of your profile
page.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -2,17 +2,21 @@ import React from 'react'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
import { Button } from './button'
import { Button, SizeType } from './button'
export function SignUpPrompt(props: { label?: string; className?: string }) {
const { label, className } = props
export function SignUpPrompt(props: {
label?: string
className?: string
size?: SizeType
}) {
const { label, className, size = 'lg' } = props
const user = useUser()
return user === null ? (
<Button
onClick={withTracking(firebaseLogin, 'sign up to bet')}
className={className}
size="lg"
size={size}
color="gradient"
>
{label ?? 'Sign up to bet!'}

View File

@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button'
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'
export function UserLink(props: {
name: string
@ -65,10 +66,13 @@ export function UserPage(props: { user: User }) {
const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
setShowConfetti(claimedMana)
const showBettingStreak = router.query['show'] === 'betting-streak'
setShowBettingStreakModal(showBettingStreak)
}, [router])
const profit = user.profitCached.allTime
@ -80,9 +84,14 @@ export function UserPage(props: { user: User }) {
description={user.bio ?? ''}
url={`/${user.username}`}
/>
{showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
)}
{showConfetti ||
(showBettingStreakModal && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
))}
<BettingStreakModal
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
/>
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
@ -114,22 +123,34 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<Row className={'items-center gap-2'}>
<span className="text-2xl font-bold">{user.name}</span>
<span className="mt-1 text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Row className={'justify-between'}>
<Col>
<span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span>
</Col>
<Col className={'justify-center'}>
<Row className={'gap-3'}>
<Col className={'items-center text-gray-500'}>
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>
<span>profit</span>
</Col>
<Col
className={'cursor-pointer items-center text-gray-500'}
onClick={() => setShowBettingStreakModal(true)}
>
<span>🔥{user.currentBettingStreak ?? 0}</span>
<span>streak</span>
</Col>
</Row>
</Col>
</Row>
<span className="text-gray-500">@{user.username}</span>
<Spacer h={4} />
{user.bio && (
<>

View File

@ -38,7 +38,7 @@ export function YesNoSelector(props: {
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
selected == 'YES'
? 'bg-primary text-white'
: 'text-primary bg-transparent',
: 'text-primary bg-white',
btnClassName
)}
onClick={() => onSelect('YES')}
@ -55,7 +55,7 @@ export function YesNoSelector(props: {
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
selected == 'NO'
? 'bg-red-400 text-white'
: 'bg-transparent text-red-400',
: 'bg-white text-red-400',
btnClassName
)}
onClick={() => onSelect('NO')}

View File

@ -67,7 +67,7 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
const incomeSourceTypes = ['bonus', 'tip', 'loan']
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day]

View File

@ -1,8 +1,10 @@
import { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { Contract } from 'common/contract'
import { DOMAIN } from 'common/envs/constants'
import { useState } from 'react'
import { AnswersGraph } from 'web/components/answers/answers-graph'
import BetRow from 'web/components/bet-row'
import { BetInline } from 'web/components/bet-inline'
import { Button } from 'web/components/button'
import {
BinaryResolutionOrChance,
FreeResponseResolutionOrChance,
@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useWindowSize } from 'web/hooks/use-window-size'
import { listAllBets } from 'web/lib/firebase/bets'
import {
contractPath,
@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const href = `https://${DOMAIN}${contractPath(contract)}`
const { height: windowHeight } = useWindowSize()
const { setElem, height: topSectionHeight } = useMeasureSize()
const paddingBottom = 8
const { setElem, height: graphHeight } = useMeasureSize()
const graphHeight =
windowHeight && topSectionHeight
? windowHeight - topSectionHeight - paddingBottom
: 0
const [betPanelOpen, setBetPanelOpen] = useState(false)
const [probAfter, setProbAfter] = useState<number>()
return (
<Col className="w-full flex-1 bg-white">
<div className="relative flex flex-col pt-2" ref={setElem}>
<Col className="h-[100vh] w-full bg-white">
<div className="relative flex flex-col pt-2">
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
<SiteLink href={href}>{question}</SiteLink>
</div>
@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
disabled
/>
{(isBinary || isPseudoNumeric) &&
tradingAllowed(contract) &&
!betPanelOpen && (
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
Bet
</Button>
)}
{isBinary && (
<Row className="items-center gap-4">
{tradingAllowed(contract) && (
<BetRow
contract={contract as CPMMBinaryContract}
betPanelClassName="scale-75"
/>
)}
<BinaryResolutionOrChance contract={contract} />
</Row>
<BinaryResolutionOrChance
contract={contract}
probAfter={probAfter}
className="items-center"
/>
)}
{isPseudoNumeric && (
<Row className="items-center gap-4">
{tradingAllowed(contract) && (
<BetRow contract={contract} betPanelClassName="scale-75" />
)}
<PseudoNumericResolutionOrExpectation contract={contract} />
</Row>
<PseudoNumericResolutionOrExpectation contract={contract} />
)}
{outcomeType === 'FREE_RESPONSE' && (
@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
<Spacer h={2} />
</div>
<div className="mx-1" style={{ paddingBottom }}>
{(isBinary || isPseudoNumeric) && betPanelOpen && (
<BetInline
contract={contract as any}
setProbAfter={setProbAfter}
onClose={() => setBetPanelOpen(false)}
className="self-center"
/>
)}
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph
contract={contract}

View File

@ -31,7 +31,10 @@ import {
import { TrendingUpIcon } from '@heroicons/react/outline'
import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
import {
BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/numeric-constants'
import { groupBy, sum, uniq } from 'lodash'
import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: {
(n) => n.sourceType
)
for (const sourceType in groupedNotificationsBySourceType) {
// Source title splits by contracts and groups
// Source title splits by contracts, groups, betting streak bonus
const groupedNotificationsBySourceTitle = groupBy(
groupedNotificationsBySourceType[sourceType],
(notification) => {
return notification.sourceTitle ?? notification.sourceContractTitle
}
)
for (const contractId in groupedNotificationsBySourceTitle) {
const notificationsForContractId =
groupedNotificationsBySourceTitle[contractId]
if (notificationsForContractId.length === 1) {
newNotifications.push(notificationsForContractId[0])
for (const sourceTitle in groupedNotificationsBySourceTitle) {
const notificationsForSourceTitle =
groupedNotificationsBySourceTitle[sourceTitle]
if (notificationsForSourceTitle.length === 1) {
newNotifications.push(notificationsForSourceTitle[0])
continue
}
let sum = 0
notificationsForContractId.forEach(
notificationsForSourceTitle.forEach(
(notification) =>
notification.sourceText &&
(sum = parseInt(notification.sourceText) + sum)
)
const uniqueUsers = uniq(
notificationsForContractId.map((notification) => {
notificationsForSourceTitle.map((notification) => {
return notification.sourceUserUsername
})
)
const newNotification = {
...notificationsForContractId[0],
...notificationsForSourceTitle[0],
sourceText: sum.toString(),
sourceUserUsername:
uniqueUsers.length > 1
? MULTIPLE_USERS_KEY
: notificationsForContractId[0].sourceType,
: notificationsForSourceTitle[0].sourceType,
}
newNotifications.push(newNotification)
}
@ -362,7 +365,8 @@ function IncomeNotificationItem(props: {
justSummary?: boolean
}) {
const { notification, justSummary } = props
const { sourceType, sourceUserName, sourceUserUsername } = notification
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
notification
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
@ -370,19 +374,74 @@ function IncomeNotificationItem(props: {
setNotificationsAsSeen([notification])
}, [notification])
function getReasonForShowingIncomeNotification(simple: boolean) {
function reasonAndLink(simple: boolean) {
const { sourceText } = notification
let reasonText = ''
if (sourceType === 'bonus' && sourceText) {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} unique traders`
} unique traders on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you` : `in tips on`
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus' && sourceText) {
reasonText = `for your ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
}-day`
}
return reasonText
return (
<>
{reasonText}
{sourceType === 'betting_streak_bonus' ? (
simple ? (
<span className={'ml-1 font-bold'}>Betting Streak</span>
) : (
<SiteLink
className={'ml-1 font-bold'}
href={'/betting-streak-bonus'}
>
Betting Streak
</SiteLink>
)
) : (
<QuestionOrGroupLink
notification={notification}
ignoreClick={isMobile}
/>
)}
</>
)
}
const incomeNotificationLabel = () => {
return sourceText ? (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
) : (
<div />
)
}
const getIncomeSourceUrl = () => {
const {
sourceId,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
} = notification
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
sourceType
)}`
}
if (justSummary) {
@ -392,19 +451,9 @@ function IncomeNotificationItem(props: {
<div className={'flex pl-1 sm:pl-0'}>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<div className={'mr-1 text-black'}>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
{incomeNotificationLabel()}
</div>
<span className={'flex truncate'}>
{getReasonForShowingIncomeNotification(true)}
<QuestionOrGroupLink
notification={notification}
ignoreClick={isMobile}
/>
</span>
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
</div>
</div>
</div>
@ -421,18 +470,16 @@ function IncomeNotificationItem(props: {
>
<div className={'relative'}>
<SiteLink
href={getSourceUrl(notification) ?? ''}
href={getIncomeSourceUrl() ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<div className={'line-clamp-2 flex max-w-xl shrink '}>
<div className={'inline'}>
<span className={'mr-1'}>
<NotificationTextLabel notification={notification} />
</span>
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
</div>
<span>
{sourceType != 'bonus' &&
{sourceType === 'tip' &&
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
<span className={'mr-1 truncate'}>Multiple users</span>
) : (
@ -443,8 +490,7 @@ function IncomeNotificationItem(props: {
short={true}
/>
))}
{getReasonForShowingIncomeNotification(false)} {' on'}
<QuestionOrGroupLink notification={notification} />
{reasonAndLink(false)}
</span>
</div>
</Row>
@ -794,9 +840,6 @@ function getSourceUrl(notification: Notification) {
// User referral:
if (sourceType === 'user' && !sourceContractSlug)
return `/${sourceUserUsername}`
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
@ -885,12 +928,6 @@ function NotificationTextLabel(props: {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
)
} else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
return (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
)
} else if (sourceType === 'bet' && sourceText) {
return (
<>