Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0797396
feat: update setup component for notification modal
kushagrasarathe Sep 9, 2025
dd31bc9
feat: notifications pop-up, ui and hook
kushagrasarathe Sep 9, 2025
c2bbb0d
feat: use react sdk methods from onesignal
kushagrasarathe Sep 9, 2025
4577606
Merge branch 'peanut-wallet-dev' into feat/notifications
kushagrasarathe Sep 10, 2025
5ec9b93
Merge branch 'peanut-wallet-dev' into feat/notifications
kushagrasarathe Sep 10, 2025
9623812
Merge branch 'peanut-wallet-dev' into feat/notifications
kushagrasarathe Sep 17, 2025
358d7d7
Merge branch 'peanut-wallet-dev' into feat/notifications
kushagrasarathe Sep 23, 2025
bc7a06a
feat: resolve cr comments
kushagrasarathe Sep 23, 2025
0b2f69b
Merge branch 'peanut-wallet-dev' into feat/notifications
kushagrasarathe Sep 23, 2025
8e01811
feat: notifications routes sevice
kushagrasarathe Sep 23, 2025
835f7b0
feat: homepage notification bell for navigation
kushagrasarathe Sep 23, 2025
c1baee2
feat: notificaitons page
kushagrasarathe Sep 23, 2025
b295e82
fix: resolve more cr comments
kushagrasarathe Sep 23, 2025
88af85c
fix: notif modal ux copy
kushagrasarathe Sep 23, 2025
940e0bb
Merge branch 'feat/notifications-fixed' into feat/notifications-center
kushagrasarathe Sep 23, 2025
345febd
chore: format
kushagrasarathe Sep 23, 2025
3eff7ad
fix: pr review comments #1
kushagrasarathe Sep 24, 2025
e89783c
fix: more pr review comments #2
kushagrasarathe Sep 24, 2025
351e9fe
Merge branch 'feat/notifications-fixed' into feat/notifications-center
kushagrasarathe Sep 24, 2025
c9774fb
feat: add error state in notis page
kushagrasarathe Sep 24, 2025
2ffda52
chore: style
kushagrasarathe Sep 24, 2025
c49c5d7
fix: resolve cr comment
kushagrasarathe Sep 24, 2025
e352b5f
Merge pull request #1248 from peanutprotocol/feat/notifications-center
kushagrasarathe Sep 24, 2025
9c8dad4
feat: auto mark notif on read when user visits notifs page
kushagrasarathe Sep 29, 2025
a5a9397
Merge branch 'peanut-wallet-dev' into feat/notifications-fixed
kushagrasarathe Sep 30, 2025
1b8f4ae
Merge branch 'peanut-wallet-dev' into feat/notifications-fixed
kushagrasarathe Oct 1, 2025
99358f3
fix: update notifications setup modal ui
kushagrasarathe Oct 1, 2025
bc8e290
feat: use banners corousel for notifs banner
kushagrasarathe Oct 1, 2025
276f416
fix: notif hub responsiveness
kushagrasarathe Oct 1, 2025
7281c38
feat: mark notif read on click
kushagrasarathe Oct 2, 2025
8ae0b8d
Merge branch 'peanut-wallet-dev' into feat/notifications-fixed
kushagrasarathe Oct 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,8 @@ export PROMO_LIST={}
export NEXT_PUBLIC_VAPID_PUBLIC_KEY=
export VAPID_PRIVATE_KEY=
export NEXT_PUBLIC_VAPID_SUBJECT="mailto:[email protected]"

# one signal
export NEXT_PUBLIC_ONESIGNAL_APP_ID=
export NEXT_PUBLIC_SAFARI_WEB_ID=
export NEXT_PUBLIC_ONESIGNAL_WEBHOOK=
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"react-fast-marquee": "^1.6.5",
"react-ga4": "^2.1.0",
"react-hook-form": "^7.53.2",
"react-onesignal": "^3.2.3",
"react-qr-code": "^2.0.15",
"react-redux": "^9.2.0",
"react-tooltip": "^5.28.0",
Expand Down
319 changes: 165 additions & 154 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions public/onesignal/OneSignalSDKWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// use the stable v16 service worker path per onesignal docs
// note: onesignal does not publish minor-pinned sw paths; use v16 channel
importScripts('https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.sw.js')
30 changes: 23 additions & 7 deletions src/app/(mobile-ui)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ import { PostSignupActionManager } from '@/components/Global/PostSignupActionMan
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useClaimBankFlow } from '@/context/ClaimBankFlowContext'
import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
import SetupNotificationsModal from '@/components/Notifications/SetupNotificationsModal'
import { useNotifications } from '@/hooks/useNotifications'
import NotificationNavigation from '@/components/Notifications/NotificationNavigation'
import useKycStatus from '@/hooks/useKycStatus'
import HomeBanners from '@/components/Home/HomeBanners'

const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500')
const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds

export default function Home() {
const { showPermissionModal } = useNotifications()
const { balance, address, isFetchingBalance } = useWallet()
Comment on lines +51 to 52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Avoid double-instantiating useNotifications; modal never clears
Home stores showPermissionModal from its own hook instance, but SetupNotificationsModal spins up another copy of useNotifications. When the user clicks “Not now” / allow, the modal updates only its local state, leaving the parent’s showPermissionModal stuck at true. The banner/modal remains mounted (just hidden) and the add-money prompt is now permanently suppressed by the guard at Line 206.

Lift the hook instance into Home and pass the state/handlers down so both sites share the same state container, e.g.:

-    const { showPermissionModal } = useNotifications()
+    const notifications = useNotifications()
+    const { showPermissionModal } = notifications
...
-                {showPermissionModal && <SetupNotificationsModal />}
+                {showPermissionModal && <SetupNotificationsModal notifications={notifications} />}

Then update SetupNotificationsModal to consume the injected props instead of calling useNotifications again. Without this change the notification permission flow can’t recover.

Also applies to: 264-265

🤖 Prompt for AI Agents
In src/app/(mobile-ui)/home/page.tsx around lines 51-52 (and also affecting
lines 264-265), Home calls useNotifications and also SetupNotificationsModal
calls useNotifications again, causing two independent instances so modal updates
don't clear Home's showPermissionModal; lift the notification hook into Home and
pass the required state/handlers (e.g., showPermissionModal, setter, and any
open/close/permission handlers) as props into SetupNotificationsModal, then
remove the useNotifications call from SetupNotificationsModal and update it to
consume the injected props; apply the same pattern for the other occurrence at
lines 264-265 so both components share the same state container and modal
visibility updates propagate correctly.

const { resetFlow: resetClaimBankFlow } = useClaimBankFlow()
const { resetWithdrawFlow } = useWithdrawFlow()
Expand Down Expand Up @@ -192,13 +196,14 @@ export default function Home() {
// show if:
// 1. balance is zero.
// 2. user hasn't seen this prompt in the current session.
// 3. the iOS PWA install modal is not currently active.
// 4. the balance warning modal is not currently active.
// this allows the modal on any device (iOS/Android) and in any display mode (PWA/browser),
// as long as the PWA modal (which is iOS & browser-specific) isn't taking precedence.
// 3. setup notifications modal is not visible (priority: setup modal > add money prompt).
// 4. the iOS PWA install modal is not currently active.
// 5. the balance warning modal is not currently active.
// 6. no other post-signup modal is active.
if (
balance === 0n &&
!hasSeenAddMoneyPromptThisSession &&
!showPermissionModal &&
!showIOSPWAInstallModal &&
!showBalanceWarningModal &&
!isPostSignupActionModalVisible
Expand All @@ -207,7 +212,14 @@ export default function Home() {
sessionStorage.setItem('hasSeenAddMoneyPromptThisSession', 'true')
}
}
}, [balance, isFetchingBalance, showIOSPWAInstallModal, showBalanceWarningModal])
}, [
balance,
isFetchingBalance,
showPermissionModal,
showIOSPWAInstallModal,
showBalanceWarningModal,
isPostSignupActionModalVisible,
])

if (isLoading) {
return <PeanutLoading coverFullScreen />
Expand All @@ -218,7 +230,10 @@ export default function Home() {
<div className="h-full w-full space-y-6 p-5">
<div className="flex items-center justify-between gap-2">
<UserHeader username={username!} fullName={userFullName} isVerified={isUserKycApproved} />
<SearchUsers />
<div className="flex items-center">
<SearchUsers />
<NotificationNavigation />
</div>
</div>
<div className="space-y-4">
<ActionButtonGroup>
Expand All @@ -244,9 +259,10 @@ export default function Home() {
/>
</ActionButtonGroup>
</div>

<HomeBanners />

{showPermissionModal && <SetupNotificationsModal />}

<HomeHistory username={username ?? undefined} />
{/* Render the new Rewards Modal
<RewardsModal />
Expand Down
237 changes: 237 additions & 0 deletions src/app/(mobile-ui)/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
'use client'

import PageContainer from '@/components/0_Bruddle/PageContainer'
import Card, { CardPosition } from '@/components/Global/Card'
import NavHeader from '@/components/Global/NavHeader'
import PeanutLoading from '@/components/Global/PeanutLoading'
import { notificationsApi, type InAppItem } from '@/services/notifications'
import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import { PEANUTMAN_LOGO } from '@/assets'
import Link from 'next/link'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { Button } from '@/components/0_Bruddle'

export default function NotificationsPage() {
const loadingRef = useRef<HTMLDivElement>(null)
const [notifications, setNotifications] = useState<InAppItem[]>([])
const [nextPageCursor, setNextPageCursor] = useState<string | null>(null)
const [isInitialLoading, setIsInitialLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [loadMoreError, setLoadMoreError] = useState<string | null>(null)
const [markedIds, setMarkedIds] = useState<Set<string>>(new Set())

const loadInitialPage = async () => {
// load the first page of notifications
setIsInitialLoading(true)
setErrorMessage(null)
try {
const res = await notificationsApi.list({ limit: 20 })
setNotifications(res.items)
setNextPageCursor(res.nextCursor)
} catch (_e) {
// set an error state if api fails
setErrorMessage('Failed to load notifications. Please try again.')
setNotifications([])
setNextPageCursor(null)
} finally {
setIsInitialLoading(false)
}
}

useEffect(() => {
void loadInitialPage()
}, [])

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0]
if (target.isIntersecting && nextPageCursor && !isLoadingMore) {
void loadNextPage()
}
},
{ threshold: 0.1 }
)
const element = loadingRef.current
if (element) observer.observe(element)
return () => {
if (element) observer.unobserve(element)
}
}, [nextPageCursor, isLoadingMore])

const loadNextPage = async () => {
// load the next page when the sentinel enters the viewport
if (!nextPageCursor) return
setIsLoadingMore(true)
setLoadMoreError(null)
try {
const res = await notificationsApi.list({ limit: 20, cursor: nextPageCursor })
setNotifications((prev) => [...prev, ...res.items])
setNextPageCursor(res.nextCursor)
} catch (_e) {
// show error below the list and allow retry
setLoadMoreError('Failed to load more notifications. Tap to retry.')
} finally {
setIsLoadingMore(false)
}
}

const grouped = useMemo(() => {
const today = new Date()
const groups: Array<{ header: string; items: InAppItem[] }> = []
let lastKey: string | null = null
for (const notif of notifications) {
const d = new Date(notif.createdAt)
const grp = getDateGroup(d, today)
const key = getDateGroupKey(d, grp)
const header = formatGroupHeaderDate(d, grp, today)
if (key !== lastKey) {
groups.push({ header, items: [notif] })
lastKey = key
} else {
groups[groups.length - 1].items.push(notif)
}
}
return groups
}, [notifications])

// mark notification as read when clicked
const handleNotificationClick = (notifId: string) => {
const notif = notifications.find((n) => n.id === notifId)
if (!notif || notif.state.readAt || markedIds.has(notifId)) return

// optimistically update ui
setNotifications((prev) =>
prev.map((it) =>
it.id === notifId ? { ...it, state: { ...it.state, readAt: new Date().toISOString() } } : it
)
)
setMarkedIds((prev) => new Set([...prev, notifId]))

// fire-and-forget api call to mark as read
void notificationsApi
.markRead([notifId])
.then(() => {
// broadcast update so other ui (e.g. bell icon) can refresh unread count
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notifications:updated'))
}
})
.catch(() => {})
}

if (isInitialLoading && notifications.length === 0) {
return <PeanutLoading />
}

return (
<PageContainer>
<div className="h-full w-full space-y-6">
<NavHeader title="Notifications" />
<div className="h-full w-full">
{/* error banner for partial failures */}
{!isInitialLoading && notifications.length > 0 && errorMessage && (
<div className="px-2">
<EmptyState title="Something went wrong" description={errorMessage ?? ''} icon="bell" />
<div className="mt-4 flex justify-center">
<Button shadowSize="4" onClick={() => void loadInitialPage()}>
Retry
</Button>
</div>
</div>
)}

{!!grouped.length ? (
grouped.map((group, groupIdx) => {
return (
<React.Fragment key={groupIdx}>
<div className="mb-2 mt-4 px-1 text-sm font-semibold capitalize">
{group.header}
</div>
{group.items.map((notif, idx) => {
let position: CardPosition = 'middle'
if (group.items.length === 1) position = 'single'
else if (idx === 0) position = 'first'
else if (idx === group.items.length - 1) position = 'last'
const href = notif.ctaDeeplink
return (
<Card
key={notif.id}
position={position}
className="relative flex min-h-16 w-full items-center justify-center px-5 py-2"
data-notification-id={notif.id}
>
<Link
href={href ?? ''}
className="flex w-full items-center gap-3"
data-notification-id={notif.id}
onClick={() => handleNotificationClick(notif.id)}
>
<Image
src={notif.iconUrl ?? PEANUTMAN_LOGO}
alt="icon"
width={32}
height={32}
className="size-8 min-w-8 self-center"
/>

<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-2">
<div className="line-clamp-2 font-semibold">
{notif.title}
</div>
</div>
{notif.body ? (
<div className="line-clamp-2 text-sm text-gray-600">
{notif.body}
</div>
) : null}
</div>
</Link>
{!notif.state.readAt ? (
<span className="absolute right-2 top-2 size-2 rounded-full bg-orange-2" />
) : null}
</Card>
)
})}
</React.Fragment>
)
})
) : (
<div>
{errorMessage ? (
<div className="px-2">
<EmptyState title="Something went wrong" description={errorMessage} icon="bell" />
<div className="mt-4 flex justify-center">
<Button shadowSize="4" onClick={() => void loadInitialPage()}>
Retry
</Button>
</div>
</div>
) : (
<EmptyState
title="No notifications yet!"
description="You will see your notifications here."
icon="bell"
/>
)}
</div>
)}
<div ref={loadingRef} className="w-full py-4">
{isLoadingMore && <div className="w-full text-center">Loading more...</div>}
{loadMoreError && (
<div className="w-full text-center text-sm text-red">
<button onClick={() => void loadNextPage()} className="underline">
{loadMoreError}
</button>
</div>
)}
</div>
</div>
</div>
</PageContainer>
)
}
3 changes: 3 additions & 0 deletions src/components/Global/Icons/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { FailedIcon } from './failed'
import { ChevronDownIcon } from './chevron-down'
import { DoubleCheckIcon } from './double-check'
import { QuestionMarkIcon } from './question-mark'
import { BellIcon } from './bell'
import { ShieldIcon } from './shield'

// available icon names
Expand All @@ -68,6 +69,7 @@ export type IconName =
| 'arrow-up'
| 'arrow-up-right'
| 'bank'
| 'bell'
| 'camera'
| 'check'
| 'chevron-up'
Expand Down Expand Up @@ -136,6 +138,7 @@ const iconComponents: Record<IconName, ComponentType<SVGProps<SVGSVGElement>>> =
'arrow-up': ArrowUpIcon,
'arrow-up-right': ArrowUpRightIcon,
bank: BankIcon,
bell: BellIcon,
badge: BadgeIcon,
camera: CameraIcon,
check: CheckIcon,
Expand Down
10 changes: 10 additions & 0 deletions src/components/Global/Icons/bell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FC, SVGProps } from 'react'

export const BellIcon: FC<SVGProps<SVGSVGElement>> = (props) => (
<svg width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M9.00003 16.5488C9.9026 16.5488 10.6411 15.8104 10.6411 14.9078H7.35901C7.35901 15.8104 8.09747 16.5488 9.00003 16.5488ZM13.9231 11.6258V7.52319C13.9231 5.00421 12.5857 2.89549 10.2308 2.33755V1.7796C10.2308 1.09857 9.68106 0.548828 9.00003 0.548828C8.31901 0.548828 7.76926 1.09857 7.76926 1.7796V2.33755C5.4226 2.89549 4.07696 4.99601 4.07696 7.52319V11.6258L2.43593 13.2668V14.0873H15.5641V13.2668L13.9231 11.6258ZM12.2821 12.4463H5.71798V7.52319C5.71798 5.48832 6.95696 3.83088 9.00003 3.83088C11.0431 3.83088 12.2821 5.48832 12.2821 7.52319V12.4463ZM5.37337 1.84524L4.20003 0.671905C2.2308 2.17344 0.934391 4.48729 0.819519 7.11293H2.46054C2.58362 4.93857 3.69952 3.03498 5.37337 1.84524ZM15.5395 7.11293H17.1805C17.0575 4.48729 15.7611 2.17344 13.8 0.671905L12.6349 1.84524C14.2923 3.03498 15.4164 4.93857 15.5395 7.11293Z"
fill="currentColor"
/>
</svg>
)
7 changes: 6 additions & 1 deletion src/components/Home/HomeBanners/BannerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ interface BannerCardProps {
}

const BannerCard = ({ title, description, icon, onClose }: BannerCardProps) => {
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
onClose()
}

return (
<Card className="embla__slide relative flex flex-row items-center justify-around p-2">
<div className="absolute right-2 top-2">
<Icon onClick={onClose} name="cancel" size={10} />
<Icon onClick={handleClose} name="cancel" size={10} />
</div>

<div className="flex size-8 items-center justify-center rounded-full bg-primary-1">
Expand Down
Loading
Loading