From bc975e3074b3158dd05e540ed575a4f477100081 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 7 Aug 2026 14:45:20 +0200 Subject: [PATCH 1/4] feat(support): unread badge on the Support nav icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of TASK-21141. The backend now writes an in-app notification row for every support reply; this shows it. The Support icon in the mobile nav gets a pink dot while support has replied and the user has not opened the chat. Opening the drawer clears it. The count is server truth, read from /notifications/unread-count?category=support — the Crisp widget is a sandboxed iframe on web and an event-less plugin on native, so the client cannot work this out for itself. Clearing hangs off isSupportModalOpen, which is the one flag every entry sets before anything opens — the nav tap, openSupportWithMessage(), the push deep link and the Capacitor path. One effect covers all four. SupportDeepLink handles /home?support=open, the link a support push carries. The pink dot was copy-pasted in three places and the badge would have made a fourth, so it is now one IndicatorDot component. The three call sites render the same as before — twMerge resolves the size and animation overrides. The name is deliberately neutral: on a transaction card the dot means pending, on the perk carousel it means claimable. Do not merge before the backend PR is deployed. An old backend ignores the category param and would light the badge for any unread notification. --- src/app/(mobile-ui)/layout.tsx | 9 ++- src/components/Global/IndicatorDot/index.tsx | 14 ++++ .../Global/SupportDeepLink/index.tsx | 25 +++++++ .../__tests__/SupportDrawer.test.tsx | 30 ++++++++ src/components/Global/SupportDrawer/index.tsx | 16 +++++ .../Global/WalletNavigation/index.tsx | 10 ++- .../Home/HomeCarouselCTA/CarouselCTA.tsx | 3 +- .../Profile/components/ProfileMenuItem.tsx | 7 +- .../TransactionDetails/TransactionCard.tsx | 3 +- src/hooks/__tests__/useSupportUnread.test.ts | 72 +++++++++++++++++++ src/hooks/useSupportUnread.ts | 45 ++++++++++++ src/services/notifications.ts | 19 ++++- 12 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 src/components/Global/IndicatorDot/index.tsx create mode 100644 src/components/Global/SupportDeepLink/index.tsx create mode 100644 src/hooks/__tests__/useSupportUnread.test.ts create mode 100644 src/hooks/useSupportUnread.ts diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 1e39cd4ff6..d1e18d648b 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -10,11 +10,12 @@ import BackendErrorScreen from '@/components/Global/BackendErrorScreen' import { useAuth } from '@/context/authContext' import classNames from 'classnames' import { usePathname } from 'next/navigation' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' import { twMerge } from 'tailwind-merge' import '../../styles/globals.css' import QRScannerOverlay from '@/components/Global/QRScannerOverlay' import SecurityVerificationOverlay from '@/components/Global/SecurityVerificationOverlay' +import SupportDeepLink from '@/components/Global/SupportDeepLink' import SupportDrawer from '@/components/Global/SupportDrawer' import JoinWaitlistPage from '@/components/Invites/JoinWaitlistPage' import { useRouter } from 'next/navigation' @@ -254,6 +255,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => { + {/* Suspense is required: nuqs reads useSearchParams, which triggers + a client-side-rendering bailout without a boundary. */} + + + + diff --git a/src/components/Global/IndicatorDot/index.tsx b/src/components/Global/IndicatorDot/index.tsx new file mode 100644 index 0000000000..998073f645 --- /dev/null +++ b/src/components/Global/IndicatorDot/index.tsx @@ -0,0 +1,14 @@ +import { twMerge } from 'tailwind-merge' + +/** + * The small pink status dot. + * + * Neutral name on purpose: it marks "pending" on a transaction card, + * "claimable" on a perk carousel card, and "unread" on the support nav icon. + * Pass className for size, animation or position overrides. + */ +const IndicatorDot = ({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) => ( + +) + +export default IndicatorDot diff --git a/src/components/Global/SupportDeepLink/index.tsx b/src/components/Global/SupportDeepLink/index.tsx new file mode 100644 index 0000000000..15f8a2cbde --- /dev/null +++ b/src/components/Global/SupportDeepLink/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useModalsContext } from '@/context/ModalsContext' +import { parseAsString, useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +/** + * Opens the support drawer for `/home?support=open`, the deep link a support + * reply push carries. The param is cleared right after so a refresh or a back + * navigation does not reopen the drawer. Renders nothing. + */ +const SupportDeepLink = () => { + const { setIsSupportModalOpen } = useModalsContext() + const [{ support }, setQuery] = useQueryStates({ support: parseAsString }) + + useEffect(() => { + if (support !== 'open') return + setIsSupportModalOpen(true) + setQuery({ support: null }) + }, [support, setIsSupportModalOpen, setQuery]) + + return null +} + +export default SupportDeepLink diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index f6c0e4f712..7ba48c9140 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -41,6 +41,15 @@ jest.mock('@/context/ModalsContext', () => ({ supportPrefilledMessage: undefined, }), })) +// Opening the drawer clears the support unread badge. That call is not what +// this file guards, and serverFetch reaches for Capacitor Preferences, which +// jsdom has no shim for. +const mockMarkAllRead = jest.fn(async () => ({ ok: true })) +jest.mock('@/services/notifications', () => ({ + notificationsApi: { + markAllRead: (category: string) => mockMarkAllRead(category), + }, +})) jest.mock('@/hooks/useCrispUserData', () => ({ useCrispUserData: () => mockUseCrispUserData(), })) @@ -106,6 +115,27 @@ describe('SupportDrawer Crisp session gate — web iframe', () => { }) }) +describe('SupportDrawer — support unread badge', () => { + beforeEach(() => { + mockUseCrispUserData.mockReset().mockReturnValue({ userId: 'user-abc', email: 'a@b.com' }) + mockUseCrispTokenId.mockReset().mockReturnValue('token-abc') + mockIsCapacitor.mockReset().mockReturnValue(false) + mockMarkAllRead.mockClear() + }) + + it('clears the support badge and tells the rest of the app when the drawer opens', async () => { + const onUpdated = jest.fn() + window.addEventListener('notifications:updated', onUpdated) + + render() + + await waitFor(() => expect(mockMarkAllRead).toHaveBeenCalledWith('support')) + await waitFor(() => expect(onUpdated).toHaveBeenCalled()) + + window.removeEventListener('notifications:updated', onUpdated) + }) +}) + describe('SupportDrawer — Crisp load-failure fallback', () => { beforeEach(() => { mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined }) diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 0b2e506a5a..efead4c4fd 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -10,6 +10,7 @@ import { useVisualViewport } from '@/hooks/useVisualViewport' import PeanutLoading from '../PeanutLoading' import { Button } from '@/components/0_Bruddle/Button' import { SUPPORT_EMAIL } from '@/constants/crisp' +import { notificationsApi } from '@/services/notifications' import { isCapacitor } from '@/utils/capacitor' const DISMISS_THRESHOLD = 100 @@ -51,6 +52,21 @@ const SupportDrawer = () => { if (isSupportModalOpen) setHasBeenOpened(true) }, [isSupportModalOpen]) + /* + * Clear the support unread badge. This flag is the single choke point for + * "the user opened the chat" — the nav tap, openSupportWithMessage(), the + * push deep link and the Capacitor path all set it before anything opens, + * so one effect covers every entry. + */ + useEffect(() => { + if (!isSupportModalOpen) return + notificationsApi + .markAllRead('support') + .then(() => window.dispatchEvent(new CustomEvent('notifications:updated'))) + // A failed mark-read only means the badge stays on a bit longer. + .catch(() => {}) + }, [isSupportModalOpen]) + const handleRetry = useCallback(() => { setIsCrispFailed(false) setIsCrispReady(false) diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index ad9000ab11..48cbda7b7a 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -2,8 +2,10 @@ import PEANUT_LOGO from '@/assets/logos/peanut-logo.svg' import DirectSendQr from '@/components/Global/DirectSendQR' import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/Icon' +import IndicatorDot from '@/components/Global/IndicatorDot' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useModalsContext } from '@/context/ModalsContext' +import { useSupportUnread } from '@/hooks/useSupportUnread' import { useUserStore } from '@/redux/hooks' import classNames from 'classnames' import Image from 'next/image' @@ -76,6 +78,7 @@ const MobileNav: React.FC = ({ pathName }) => { const t = useTranslations('navigation') const { setIsSupportModalOpen } = useModalsContext() const { triggerHaptic } = useHaptic() + const hasUnreadSupport = useSupportUnread() return (
@@ -111,7 +114,12 @@ const MobileNav: React.FC = ({ pathName }) => { { 'text-primary-1': pathName === '/support' } )} > - + + + {hasUnreadSupport && ( + + )} + {t('support')}
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx index 660fc9c2bb..433d2f1b55 100644 --- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx +++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx @@ -1,6 +1,7 @@ 'use client' import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import IndicatorDot from '@/components/Global/IndicatorDot' import type { StaticImageData } from 'next/image' import Image from 'next/image' import { useTranslations } from 'next-intl' @@ -80,7 +81,7 @@ const CarouselCTA = ({ {/* Close button or pink dot indicator for perk claims */} {isPerkClaim ? (
-
+
) : (