Daily trading bonuses (#618)
* first commit, WIP * Give trading bonuses & paginate notifications * Move read & update into transaction * Move request bonus logic to notifs icon
This commit is contained in:
parent
53b4a28944
commit
b26648c1ce
|
@ -34,6 +34,7 @@ export type notification_source_types =
|
|||
| 'admin_message'
|
||||
| 'group'
|
||||
| 'user'
|
||||
| 'bonus'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -56,3 +57,4 @@ export type notification_reason_types =
|
|||
| 'added_you_to_group'
|
||||
| 'you_referred_user'
|
||||
| 'user_joined_to_bet_on_your_market'
|
||||
| 'unique_bettors_on_your_contract'
|
||||
|
|
|
@ -3,3 +3,4 @@ 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 = 5
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// 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
|
||||
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET'
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
||||
|
@ -52,6 +53,12 @@ type Referral = {
|
|||
category: 'REFERRAL'
|
||||
}
|
||||
|
||||
type Bonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
|
|
|
@ -57,6 +57,7 @@ export type PrivateUser = {
|
|||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
lastTimeCheckedBonuses?: number
|
||||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
|
|
@ -267,6 +267,15 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'unique_bettors_on_your_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
@ -309,6 +318,12 @@ export const createNotification = async (
|
|||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
return userToReasonTexts
|
||||
}
|
||||
|
|
139
functions/src/get-daily-bonuses.ts
Normal file
139
functions/src/get-daily-bonuses.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { log } from './utils'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { PrivateUser } from '../../common/lib/user'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet } from '../../common/lib/bet'
|
||||
const firestore = admin.firestore()
|
||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import { createNotification } from './create-notification'
|
||||
import { User } from '../../common/lib/user'
|
||||
import { Contract } from '../../common/lib/contract'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
|
||||
|
||||
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
|
||||
const QUERY_LIMIT_SECONDS = 60
|
||||
|
||||
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
|
||||
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
|
||||
async (trans) => {
|
||||
const userSnap = await trans.get(
|
||||
firestore.doc(`private-users/${auth.uid}`)
|
||||
)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const user = userSnap.data() as PrivateUser
|
||||
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
|
||||
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
|
||||
throw new APIError(
|
||||
400,
|
||||
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
|
||||
)
|
||||
await trans.update(userSnap.ref, {
|
||||
lastTimeCheckedBonuses: Date.now(),
|
||||
})
|
||||
return {
|
||||
user,
|
||||
lastTimeCheckedBonuses,
|
||||
}
|
||||
}
|
||||
)
|
||||
// TODO: switch to prod id
|
||||
// const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account
|
||||
const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account
|
||||
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
|
||||
// Get all users contracts made since implementation time
|
||||
const userContractsSnap = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>=', BONUS_START_DATE)
|
||||
.get()
|
||||
const userContracts = userContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
)
|
||||
const nullReturn = { status: 'no bets', txn: null }
|
||||
for (const contract of userContracts) {
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const contractId = contract.id
|
||||
// Get all bets made on user's contracts
|
||||
const bets = (
|
||||
await firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.where('userId', '!=', user.id)
|
||||
.get()
|
||||
).docs.map((bet) => bet.ref)
|
||||
if (bets.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
const contractBetsSnap = await trans.getAll(...bets)
|
||||
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
|
||||
|
||||
const uniqueBettorIdsBeforeLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter users for ONLY those that have made bets since the last daily bonus received time
|
||||
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter for users only present in the above list
|
||||
const newUniqueBettorIds =
|
||||
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
|
||||
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
|
||||
)
|
||||
newUniqueBettorIds.length > 0 &&
|
||||
log(
|
||||
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
|
||||
)
|
||||
if (newUniqueBettorIds.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
// Create combined txn for all unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contractId,
|
||||
uniqueBettors: newUniqueBettorIds.length,
|
||||
}
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
fromType: 'BANK',
|
||||
toId: user.id,
|
||||
toType: 'USER',
|
||||
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
|
||||
token: 'M$',
|
||||
category: 'UNIQUE_BETTOR_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
result.status != nullReturn.status &&
|
||||
log(`No bonus for user: ${user.id} - reason:`, result.status)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
|
||||
await createNotification(
|
||||
result.txn.id,
|
||||
'bonus',
|
||||
'created',
|
||||
fromUser,
|
||||
result.txn.id,
|
||||
result.txn.amount + '',
|
||||
contract,
|
||||
undefined,
|
||||
// No need to set the user id, we'll use the contract creator id
|
||||
undefined,
|
||||
contract.slug,
|
||||
contract.question
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { userId: user.id, message: 'success' }
|
||||
})
|
|
@ -38,3 +38,4 @@ export * from './create-contract'
|
|||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './get-daily-bonuses'
|
||||
|
|
|
@ -182,7 +182,6 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const { className } = props
|
||||
const router = useRouter()
|
||||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||
const mobileNavigationOptions = !user
|
||||
|
|
|
@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline'
|
|||
import clsx from 'clsx'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
import { requestBonuses } from 'web/lib/firebase/api-call'
|
||||
|
||||
export default function NotificationsIcon(props: { className?: string }) {
|
||||
const user = useUser()
|
||||
const notifications = usePreferredGroupedNotifications(user?.id, {
|
||||
const privateUser = usePrivateUser(user?.id)
|
||||
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
|
||||
unseenOnly: true,
|
||||
})
|
||||
const [seen, setSeen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!privateUser) return
|
||||
|
||||
if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000)
|
||||
requestBonuses({}).catch((error) => {
|
||||
console.log("couldn't get bonuses:", error.message)
|
||||
})
|
||||
}, [privateUser])
|
||||
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
||||
|
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
|||
<div className={'relative'}>
|
||||
{!seen && notifications && notifications.length > 0 && (
|
||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
||||
{notifications.length}
|
||||
{notifications.length > NOTIFICATIONS_PER_PAGE
|
||||
? `${NOTIFICATIONS_PER_PAGE}+`
|
||||
: notifications.length}
|
||||
</div>
|
||||
)}
|
||||
<BellIcon className={clsx(props.className)} />
|
||||
|
|
|
@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
|
|||
|
||||
export type NotificationGroup = {
|
||||
notifications: Notification[]
|
||||
sourceContractId: string
|
||||
groupedById: string
|
||||
isSeen: boolean
|
||||
timePeriod: string
|
||||
type: 'income' | 'normal'
|
||||
}
|
||||
|
||||
export function usePreferredGroupedNotifications(
|
||||
|
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
new Date(notification.createdTime).toDateString()
|
||||
)
|
||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||
// Group notifications by contract:
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const bonusNotifications = notificationsGroupedByDay.filter(
|
||||
(notification) => notification.sourceType === 'bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) => notification.sourceType !== 'bonus'
|
||||
)
|
||||
if (bonusNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
notifications: bonusNotifications,
|
||||
groupedById: 'income' + day,
|
||||
isSeen: bonusNotifications[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'income',
|
||||
})
|
||||
}
|
||||
// Group notifications by contract, filtering out bonuses:
|
||||
const groupedNotificationsByContractId = groupBy(
|
||||
notificationGroupsByDay[day],
|
||||
normalNotificationsGroupedByDay,
|
||||
(notification) => {
|
||||
return notification.sourceContractId
|
||||
}
|
||||
)
|
||||
notificationGroups = notificationGroups.concat(
|
||||
map(groupedNotificationsByContractId, (notifications, contractId) => {
|
||||
const notificationsForContractId = groupedNotificationsByContractId[
|
||||
contractId
|
||||
].sort((a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
})
|
||||
// Create a notification group for each contract within each day
|
||||
const notificationGroup: NotificationGroup = {
|
||||
notifications: groupedNotificationsByContractId[contractId].sort(
|
||||
(a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
}
|
||||
),
|
||||
sourceContractId: contractId,
|
||||
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
|
||||
notifications: notificationsForContractId,
|
||||
groupedById: contractId,
|
||||
isSeen: notificationsForContractId[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'normal',
|
||||
}
|
||||
return notificationGroup
|
||||
})
|
||||
|
|
|
@ -73,3 +73,7 @@ export function sellBet(params: any) {
|
|||
export function createGroup(params: any) {
|
||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||
}
|
||||
|
||||
export function requestBonuses(params: any) {
|
||||
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
|
||||
}
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Notification,
|
||||
notification_reason_types,
|
||||
notification_source_types,
|
||||
notification_source_update_types,
|
||||
} from 'common/notification'
|
||||
import { Notification } from 'common/notification'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
|
@ -31,47 +26,40 @@ import {
|
|||
ProbPercentLabel,
|
||||
} from 'web/components/outcome-label'
|
||||
import {
|
||||
groupNotifications,
|
||||
NotificationGroup,
|
||||
usePreferredGroupedNotifications,
|
||||
} from 'web/hooks/use-notifications'
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
|
||||
import { groupBy } from 'lodash'
|
||||
|
||||
export const NOTIFICATIONS_PER_PAGE = 30
|
||||
export const HIGHLIGHT_DURATION = 30 * 1000
|
||||
|
||||
export default function Notifications() {
|
||||
const user = useUser()
|
||||
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
|
||||
NotificationGroup[] | undefined
|
||||
>(undefined)
|
||||
const allNotificationGroups = usePreferredGroupedNotifications(user?.id, {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const groupedNotifications = usePreferredGroupedNotifications(user?.id, {
|
||||
unseenOnly: false,
|
||||
})
|
||||
|
||||
const [paginatedNotificationGroups, setPaginatedNotificationGroups] =
|
||||
useState<NotificationGroup[]>([])
|
||||
useEffect(() => {
|
||||
if (!allNotificationGroups) return
|
||||
// Don't re-add notifications that are visible right now or have been seen already.
|
||||
const currentlyVisibleUnseenNotificationIds = Object.values(
|
||||
unseenNotificationGroups ?? []
|
||||
)
|
||||
.map((n) => n.notifications.map((n) => n.id))
|
||||
.flat()
|
||||
const unseenGroupedNotifications = groupNotifications(
|
||||
allNotificationGroups
|
||||
.map((notification: NotificationGroup) => notification.notifications)
|
||||
.flat()
|
||||
.filter(
|
||||
(notification: Notification) =>
|
||||
!notification.isSeen ||
|
||||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
|
||||
)
|
||||
)
|
||||
setUnseenNotificationGroups(unseenGroupedNotifications)
|
||||
|
||||
// We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allNotificationGroups])
|
||||
if (!groupedNotifications) return
|
||||
const start = (page - 1) * NOTIFICATIONS_PER_PAGE
|
||||
const end = start + NOTIFICATIONS_PER_PAGE
|
||||
const maxNotificationsToShow = groupedNotifications.slice(start, end)
|
||||
const remainingNotification = groupedNotifications.slice(end)
|
||||
for (const notification of remainingNotification) {
|
||||
if (notification.isSeen) break
|
||||
else setNotificationsAsSeen(notification.notifications)
|
||||
}
|
||||
setPaginatedNotificationGroups(maxNotificationsToShow)
|
||||
}, [groupedNotifications, page])
|
||||
|
||||
if (user === undefined) {
|
||||
return <LoadingIndicator />
|
||||
|
@ -80,7 +68,6 @@ export default function Notifications() {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
// TODO: use infinite scroll
|
||||
return (
|
||||
<Page>
|
||||
<div className={'p-2 sm:p-4'}>
|
||||
|
@ -90,53 +77,74 @@ export default function Notifications() {
|
|||
defaultIndex={0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'New Notifications',
|
||||
content: unseenNotificationGroups ? (
|
||||
title: 'Notifications',
|
||||
content: groupedNotifications ? (
|
||||
<div className={''}>
|
||||
{unseenNotificationGroups.length === 0 &&
|
||||
"You don't have any new notifications."}
|
||||
{unseenNotificationGroups.map((notification) =>
|
||||
{paginatedNotificationGroups.length === 0 &&
|
||||
"You don't have any notifications. Try changing your settings to see more."}
|
||||
{paginatedNotificationGroups.map((notification) =>
|
||||
notification.notifications.length === 1 ? (
|
||||
<NotificationItem
|
||||
notification={notification.notifications[0]}
|
||||
key={notification.notifications[0].id}
|
||||
/>
|
||||
) : notification.type === 'income' ? (
|
||||
<IncomeNotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={notification.groupedById + notification.timePeriod}
|
||||
/>
|
||||
) : (
|
||||
<NotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={
|
||||
notification.sourceContractId +
|
||||
notification.timePeriod
|
||||
}
|
||||
key={notification.groupedById + notification.timePeriod}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{groupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
|
||||
<nav
|
||||
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
{page === 1
|
||||
? page
|
||||
: (page - 1) * NOTIFICATIONS_PER_PAGE}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-medium">
|
||||
{page * NOTIFICATIONS_PER_PAGE}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">
|
||||
{groupedNotifications.length}
|
||||
</span>{' '}
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'All Notifications',
|
||||
content: allNotificationGroups ? (
|
||||
<div className={''}>
|
||||
{allNotificationGroups.length === 0 &&
|
||||
"You don't have any notifications. Try changing your settings to see more."}
|
||||
{allNotificationGroups.map((notification) =>
|
||||
notification.notifications.length === 1 ? (
|
||||
<NotificationItem
|
||||
notification={notification.notifications[0]}
|
||||
key={notification.notifications[0].id}
|
||||
/>
|
||||
) : (
|
||||
<NotificationGroupItem
|
||||
notificationGroup={notification}
|
||||
key={
|
||||
notification.sourceContractId +
|
||||
notification.timePeriod
|
||||
<div className="flex flex-1 justify-between sm:justify-end">
|
||||
<a
|
||||
href="#"
|
||||
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() =>
|
||||
page <
|
||||
groupedNotifications?.length /
|
||||
NOTIFICATIONS_PER_PAGE && setPage(page + 1)
|
||||
}
|
||||
/>
|
||||
)
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -164,7 +172,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
|
|||
updateDoc(
|
||||
doc(db, `users/${notification.userId}/notifications/`, notification.id),
|
||||
{
|
||||
...notification,
|
||||
isSeen: true,
|
||||
viewTime: new Date(),
|
||||
}
|
||||
|
@ -173,6 +180,152 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
|
|||
return notifications
|
||||
}
|
||||
|
||||
function IncomeNotificationGroupItem(props: {
|
||||
notificationGroup: NotificationGroup
|
||||
className?: string
|
||||
}) {
|
||||
const { notificationGroup, className } = props
|
||||
const { notifications } = notificationGroup
|
||||
const numSummaryLines = 3
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
useEffect(() => {
|
||||
if (notifications.some((n) => !n.isSeen)) {
|
||||
setHighlighted(true)
|
||||
setTimeout(() => {
|
||||
setHighlighted(false)
|
||||
}, HIGHLIGHT_DURATION)
|
||||
}
|
||||
setNotificationsAsSeen(notifications)
|
||||
}, [notifications])
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) setHighlighted(false)
|
||||
}, [expanded])
|
||||
|
||||
const totalIncome = notifications.reduce(
|
||||
(acc, notification) =>
|
||||
acc +
|
||||
(notification.sourceType &&
|
||||
notification.sourceText &&
|
||||
notification.sourceType === 'bonus'
|
||||
? parseInt(notification.sourceText)
|
||||
: 0),
|
||||
0
|
||||
)
|
||||
// loop through the contracts and combine the notification items into one
|
||||
function combineNotificationsByAddingSourceTextsAndReturningTheRest(
|
||||
notifications: Notification[]
|
||||
) {
|
||||
const newNotifications = []
|
||||
const groupedNotificationsByContractId = groupBy(
|
||||
notifications,
|
||||
(notification) => {
|
||||
return notification.sourceContractId
|
||||
}
|
||||
)
|
||||
for (const contractId in groupedNotificationsByContractId) {
|
||||
const notificationsForContractId =
|
||||
groupedNotificationsByContractId[contractId]
|
||||
let sum = 0
|
||||
notificationsForContractId.forEach(
|
||||
(notification) =>
|
||||
notification.sourceText &&
|
||||
(sum = parseInt(notification.sourceText) + sum)
|
||||
)
|
||||
|
||||
const newNotification =
|
||||
notificationsForContractId.length === 1
|
||||
? notificationsForContractId[0]
|
||||
: {
|
||||
...notificationsForContractId[0],
|
||||
sourceText: sum.toString(),
|
||||
}
|
||||
newNotifications.push(newNotification)
|
||||
}
|
||||
return newNotifications
|
||||
}
|
||||
|
||||
const combinedNotifs =
|
||||
combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
|
||||
className,
|
||||
!expanded ? 'hover:bg-gray-100' : '',
|
||||
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded && (
|
||||
<span
|
||||
className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<TrendingUpIcon className={'text-primary h-7 w-7'} />
|
||||
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
||||
>
|
||||
<span>
|
||||
{'Daily Income Summary: '}
|
||||
<span className={'text-primary'}>{formatMoney(totalIncome)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<RelativeTimestamp time={notifications[0].createdTime} />
|
||||
</div>
|
||||
</Row>
|
||||
<div>
|
||||
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
|
||||
{' '}
|
||||
<div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}>
|
||||
{!expanded ? (
|
||||
<>
|
||||
{combinedNotifs
|
||||
.slice(0, numSummaryLines)
|
||||
.map((notification) => {
|
||||
return (
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
justSummary={true}
|
||||
key={notification.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className={'text-sm text-gray-500 hover:underline '}>
|
||||
{combinedNotifs.length - numSummaryLines > 0
|
||||
? 'And ' +
|
||||
(combinedNotifs.length - numSummaryLines) +
|
||||
' more...'
|
||||
: ''}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{combinedNotifs.map((notification) => (
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
key={notification.id}
|
||||
justSummary={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'mt-6 border-b border-gray-300'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationGroupItem(props: {
|
||||
notificationGroup: NotificationGroup
|
||||
className?: string
|
||||
|
@ -187,17 +340,28 @@ function NotificationGroupItem(props: {
|
|||
const numSummaryLines = 3
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
useEffect(() => {
|
||||
if (notifications.some((n) => !n.isSeen)) {
|
||||
setHighlighted(true)
|
||||
setTimeout(() => {
|
||||
setHighlighted(false)
|
||||
}, HIGHLIGHT_DURATION)
|
||||
}
|
||||
setNotificationsAsSeen(notifications)
|
||||
}, [notifications])
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) setHighlighted(false)
|
||||
}, [expanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
|
||||
className,
|
||||
!expanded ? 'hover:bg-gray-100' : ''
|
||||
!expanded ? 'hover:bg-gray-100' : '',
|
||||
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
|
@ -432,7 +596,7 @@ function NotificationSettings() {
|
|||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Referral bonuses you've received"}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've ever bet or commented on"}
|
||||
|
@ -476,17 +640,6 @@ function NotificationSettings() {
|
|||
)
|
||||
}
|
||||
|
||||
function isNotificationAboutContractResolution(
|
||||
sourceType: notification_source_types | undefined,
|
||||
sourceUpdateType: notification_source_update_types | undefined,
|
||||
contract: Contract | null | undefined
|
||||
) {
|
||||
return (
|
||||
(sourceType === 'contract' && sourceUpdateType === 'resolved') ||
|
||||
(sourceType === 'contract' && !sourceUpdateType && contract?.resolution)
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem(props: {
|
||||
notification: Notification
|
||||
justSummary?: boolean
|
||||
|
@ -522,6 +675,16 @@ function NotificationItem(props: {
|
|||
}
|
||||
}, [reasonText, sourceText])
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!notification.isSeen) {
|
||||
setHighlighted(true)
|
||||
setTimeout(() => {
|
||||
setHighlighted(false)
|
||||
}, HIGHLIGHT_DURATION)
|
||||
}
|
||||
}, [notification.isSeen])
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
@ -559,22 +722,21 @@ function NotificationItem(props: {
|
|||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
||||
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
|
||||
<div className={'flex pl-1 sm:pl-0'}>
|
||||
{sourceType != 'bonus' && (
|
||||
<UserLink
|
||||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-0 flex-shrink-0'}
|
||||
/>
|
||||
)}
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
<span className={'flex-shrink-0'}>
|
||||
{sourceType &&
|
||||
reason &&
|
||||
getReasonForShowingNotification(
|
||||
sourceType,
|
||||
reason,
|
||||
sourceUpdateType,
|
||||
undefined,
|
||||
true
|
||||
).replace(' on', '')}
|
||||
getReasonForShowingNotification(notification, true).replace(
|
||||
' on',
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
<div className={'ml-1 text-black'}>
|
||||
<NotificationTextLabel
|
||||
|
@ -593,37 +755,41 @@ function NotificationItem(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white px-2 pt-6 text-sm sm:px-4',
|
||||
highlighted && 'bg-indigo-200'
|
||||
)}
|
||||
>
|
||||
<a href={getSourceUrl()}>
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
{sourceType != 'bonus' ? (
|
||||
<Avatar
|
||||
avatarUrl={sourceUserAvatarUrl}
|
||||
size={'sm'}
|
||||
className={'mr-2'}
|
||||
username={sourceUserName}
|
||||
/>
|
||||
) : (
|
||||
<TrendingUpIcon className={'text-primary h-7 w-7'} />
|
||||
)}
|
||||
<div className={'flex-1 overflow-hidden sm:flex'}>
|
||||
<div
|
||||
className={
|
||||
'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0'
|
||||
}
|
||||
>
|
||||
{sourceType != 'bonus' && sourceUpdateType != 'closed' && (
|
||||
<UserLink
|
||||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'mr-0 flex-shrink-0'}
|
||||
/>
|
||||
)}
|
||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
||||
{sourceType && reason && (
|
||||
<div className={'inline truncate'}>
|
||||
{getReasonForShowingNotification(
|
||||
sourceType,
|
||||
reason,
|
||||
sourceUpdateType,
|
||||
undefined,
|
||||
false,
|
||||
sourceSlug
|
||||
)}
|
||||
{getReasonForShowingNotification(notification, false)}
|
||||
<a
|
||||
href={
|
||||
sourceContractCreatorUsername
|
||||
|
@ -684,13 +850,7 @@ function NotificationTextLabel(props: {
|
|||
return <span>{contract?.question || sourceContractTitle}</span>
|
||||
if (!sourceText) return <div />
|
||||
// Resolved contracts
|
||||
if (
|
||||
isNotificationAboutContractResolution(
|
||||
sourceType,
|
||||
sourceUpdateType,
|
||||
contract
|
||||
)
|
||||
) {
|
||||
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
|
||||
{
|
||||
if (sourceText === 'YES' || sourceText == 'NO') {
|
||||
return <BinaryOutcomeLabel outcome={sourceText as any} />
|
||||
|
@ -730,6 +890,12 @@ function NotificationTextLabel(props: {
|
|||
return (
|
||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||
)
|
||||
} else if (sourceType === 'bonus' && sourceText) {
|
||||
return (
|
||||
<span className="text-primary">
|
||||
{'+' + formatMoney(parseInt(sourceText))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// return default text
|
||||
return (
|
||||
|
@ -740,15 +906,13 @@ function NotificationTextLabel(props: {
|
|||
}
|
||||
|
||||
function getReasonForShowingNotification(
|
||||
source: notification_source_types,
|
||||
reason: notification_reason_types,
|
||||
sourceUpdateType: notification_source_update_types | undefined,
|
||||
contract: Contract | undefined | null,
|
||||
simple?: boolean,
|
||||
sourceSlug?: string
|
||||
notification: Notification,
|
||||
simple?: boolean
|
||||
) {
|
||||
const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } =
|
||||
notification
|
||||
let reasonText: string
|
||||
switch (source) {
|
||||
switch (sourceType) {
|
||||
case 'comment':
|
||||
if (reason === 'reply_to_users_answer')
|
||||
reasonText = !simple ? 'replied to your answer on' : 'replied'
|
||||
|
@ -768,16 +932,9 @@ function getReasonForShowingNotification(
|
|||
break
|
||||
case 'contract':
|
||||
if (reason === 'you_follow_user') reasonText = 'created a new question'
|
||||
else if (
|
||||
isNotificationAboutContractResolution(
|
||||
source,
|
||||
sourceUpdateType,
|
||||
contract
|
||||
)
|
||||
)
|
||||
reasonText = `resolved`
|
||||
else if (sourceUpdateType === 'resolved') reasonText = `resolved`
|
||||
else if (sourceUpdateType === 'closed')
|
||||
reasonText = `please resolve your question`
|
||||
reasonText = `Please resolve your question`
|
||||
else reasonText = `updated`
|
||||
break
|
||||
case 'answer':
|
||||
|
@ -805,6 +962,15 @@ function getReasonForShowingNotification(
|
|||
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||
else reasonText = 'joined because of you'
|
||||
break
|
||||
case 'bonus':
|
||||
if (reason === 'unique_bettors_on_your_contract' && sourceText)
|
||||
reasonText = !simple
|
||||
? `You had ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} unique bettors on`
|
||||
: 'You earned Mana for unique bettors:'
|
||||
else reasonText = 'You earned your daily manna'
|
||||
break
|
||||
default:
|
||||
reasonText = ''
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user