From b26648c1cec22af62b43a319e8f1fabb2cb1fc12 Mon Sep 17 00:00:00 2001
From: Ian Philips <iansphilips@gmail.com>
Date: Tue, 5 Jul 2022 11:29:26 -0600
Subject: [PATCH] Daily trading bonuses (#618)

* first commit, WIP

* Give trading bonuses & paginate notifications

* Move read & update into transaction

* Move request bonus logic to notifs icon
---
 common/notification.ts                |   2 +
 common/numeric-constants.ts           |   1 +
 common/txn.ts                         |  11 +-
 common/user.ts                        |   1 +
 functions/src/create-notification.ts  |  15 +
 functions/src/get-daily-bonuses.ts    | 139 ++++++++
 functions/src/index.ts                |   1 +
 web/components/nav/sidebar.tsx        |   1 -
 web/components/notifications-icon.tsx |  20 +-
 web/hooks/use-notifications.ts        |  39 ++-
 web/lib/firebase/api-call.ts          |   4 +
 web/pages/notifications.tsx           | 448 ++++++++++++++++++--------
 12 files changed, 525 insertions(+), 157 deletions(-)
 create mode 100644 functions/src/get-daily-bonuses.ts

diff --git a/common/notification.ts b/common/notification.ts
index 64a00a36..e90624a4 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -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'
diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts
index ef364b74..46885668 100644
--- a/common/numeric-constants.ts
+++ b/common/numeric-constants.ts
@@ -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
diff --git a/common/txn.ts b/common/txn.ts
index 0e772e0d..53b08501 100644
--- a/common/txn.ts
+++ b/common/txn.ts
@@ -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
diff --git a/common/user.ts b/common/user.ts
index d5dd0373..477139fd 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -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'
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index a32ed3bc..b63958f0 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -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
   }
diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts
new file mode 100644
index 00000000..c5c1a1b3
--- /dev/null
+++ b/functions/src/get-daily-bonuses.ts
@@ -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' }
+})
diff --git a/functions/src/index.ts b/functions/src/index.ts
index b643ff5e..e4a30761 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -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'
diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx
index 5ce9e239..ba46bd80 100644
--- a/web/components/nav/sidebar.tsx
+++ b/web/components/nav/sidebar.tsx
@@ -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
diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx
index e2618870..ac4d772f 100644
--- a/web/components/notifications-icon.tsx
+++ b/web/components/notifications-icon.tsx
@@ -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)} />
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
index c947e8d0..0a15754d 100644
--- a/web/hooks/use-notifications.ts
+++ b/web/hooks/use-notifications.ts
@@ -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
       })
diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts
index e02872ae..db41e592 100644
--- a/web/lib/firebase/api-call.ts
+++ b/web/lib/firebase/api-call.ts
@@ -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)
+}
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index f3512c56..229e8c8d 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -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}
                       />
                     )
                   )}
-                </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
-                        }
-                      />
-                    )
+                  {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>
+                      <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'}>
-            <UserLink
-              name={sourceUserName || ''}
-              username={sourceUserUsername || ''}
-              className={'mr-0 flex-shrink-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'}>
-          <Avatar
-            avatarUrl={sourceUserAvatarUrl}
-            size={'sm'}
-            className={'mr-2'}
-            username={sourceUserName}
-          />
+          {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'
               }
             >
-              <UserLink
-                name={sourceUserName || ''}
-                username={sourceUserUsername || ''}
-                className={'mr-0 flex-shrink-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 = ''
   }