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/__tests__/IndicatorDot.test.tsx b/src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx new file mode 100644 index 0000000000..a011304929 --- /dev/null +++ b/src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx @@ -0,0 +1,52 @@ +/** + * Visual-parity guard for the dot extraction. + * + * The same pink dot used to be copy-pasted in three places. Collapsing them + * onto one component is only safe if each call site still resolves to the + * classes it had before — twMerge has to win the size and animation overrides + * rather than emit both. Asserting the resolved class string pins that more + * precisely than a screenshot of a 10px dot could. + */ +import { render } from '@testing-library/react' +import IndicatorDot from '@/components/Global/IndicatorDot' + +const classesOf = (ui: React.ReactElement) => { + const { container } = render(ui) + return (container.firstChild as HTMLElement).className.split(/\s+/) +} + +describe('IndicatorDot', () => { + it('renders the shared 10px pink dot by default (perk carousel call site)', () => { + const classes = classesOf() + expect(classes).toEqual(expect.arrayContaining(['block', 'h-2.5', 'w-2.5', 'rounded-full', 'bg-primary-1'])) + }) + + it('lets a call site shrink the dot without leaving the old size behind', () => { + // TransactionCard's pending dot: h-2 w-2 animate-pulsate. + const classes = classesOf() + expect(classes).toEqual(expect.arrayContaining(['h-2', 'w-2', 'animate-pulsate', 'bg-primary-1'])) + expect(classes).not.toContain('h-2.5') + expect(classes).not.toContain('w-2.5') + }) + + it('keeps the profile menu highlight animating and labelled', () => { + const { container } = render() + const dot = container.firstChild as HTMLElement + expect(dot.className.split(/\s+/)).toEqual(expect.arrayContaining(['animate-pulse', 'h-2.5', 'w-2.5'])) + expect(dot).toHaveAttribute('aria-label', 'highlight-indicator') + }) + + it('positions the support nav badge without dropping the dot styling', () => { + const classes = classesOf() + expect(classes).toEqual( + expect.arrayContaining(['absolute', '-right-1', '-top-1', 'h-2.5', 'w-2.5', 'bg-primary-1']) + ) + }) + + it('announces the support badge to assistive tech', () => { + // aria-label on a bare span (generic role) is ignored, so the nav badge + // pairs it with role="status". + const { getByRole } = render() + expect(getByRole('status')).toHaveAttribute('aria-label', 'New support reply') + }) +}) 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..7d4c5ec884 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 (_category: string) => ({ ok: true })) +jest.mock('@/services/notifications', () => ({ + notificationsApi: { + markAllRead: (category: string) => mockMarkAllRead(category), + }, +})) jest.mock('@/hooks/useCrispUserData', () => ({ useCrispUserData: () => mockUseCrispUserData(), })) @@ -106,6 +115,51 @@ describe('SupportDrawer Crisp session gate — web iframe', () => { }) }) +describe('SupportDrawer — support unread badge', () => { + // Opening the drawer is not the same as reading the reply: the chat has to + // actually render. Clearing too eagerly buries a reply nobody saw. + 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 badge and tells the rest of the app once the chat renders', async () => { + const onUpdated = jest.fn() + window.addEventListener('notifications:updated', onUpdated) + + render() + expect(mockMarkAllRead).not.toHaveBeenCalled() + + postCrispMessage('CRISP_READY') + + await waitFor(() => expect(mockMarkAllRead).toHaveBeenCalledWith('support')) + await waitFor(() => expect(onUpdated).toHaveBeenCalled()) + + window.removeEventListener('notifications:updated', onUpdated) + }) + + it('does NOT clear the badge when Crisp fails and the user only sees the email fallback', async () => { + render() + postCrispMessage('CRISP_FAILED') + + await waitFor(() => expect(screen.getByText(SUPPORT_EMAIL)).toBeInTheDocument()) + expect(mockMarkAllRead).not.toHaveBeenCalled() + }) + + it('does not clear the badge for a logged-out visitor', async () => { + mockUseCrispUserData.mockReturnValue({ userId: undefined, email: undefined }) + mockUseCrispTokenId.mockReturnValue(undefined) + + render() + postCrispMessage('CRISP_READY') + + await waitFor(() => expect(supportIframe()).toBeInTheDocument()) + expect(mockMarkAllRead).not.toHaveBeenCalled() + }) +}) + 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..362f8436a5 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,45 @@ const SupportDrawer = () => { if (isSupportModalOpen) setHasBeenOpened(true) }, [isSupportModalOpen]) + // Guests reach this drawer too (claim and pay links mount the same layout), + // and they have no notifications — the call would just 401. + const isLoggedIn = Boolean(userData.userId) + + const clearSupportBadge = useCallback(() => { + if (!isLoggedIn) 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(() => {}) + }, [isLoggedIn]) + + /* + * Clear the support unread badge — on the web path only; the Capacitor + * effect below clears its own once the native messenger actually opens. + * + * "Opened the drawer" is not the same as "read the reply". When the Crisp + * bundle fails to load, this same component shows the email fallback + * instead, and clearing then would bury a reply nobody saw. So wait until + * the chat is really in front of the user. + * + * The closing edge matters just as much: a reply arriving while the drawer + * is open — the normal case in a live conversation — would otherwise light + * the badge with nothing new behind it, and leave it lit until the user + * opened support again. + */ + const wasShowingChat = useRef(false) + useEffect(() => { + const isShowingChat = isSupportModalOpen && isCrispReady && !isCrispFailed + if (isShowingChat) { + wasShowingChat.current = true + clearSupportBadge() + } else if (wasShowingChat.current && !isSupportModalOpen) { + wasShowingChat.current = false + clearSupportBadge() + } + }, [isSupportModalOpen, isCrispReady, isCrispFailed, clearSupportBadge]) + const handleRetry = useCallback(() => { setIsCrispFailed(false) setIsCrispReady(false) @@ -98,10 +138,23 @@ const SupportDrawer = () => { } CapacitorCrisp.openMessenger() + // The chat is now in front of the user, so the badge has done its + // job. There is no isCrispReady on this path — the native messenger + // reports nothing back — so clear it here rather than in the web + // effect above. + clearSupportBadge() // close our drawer since native UI takes over setIsSupportModalOpen(false) }) - }, [isSupportModalOpen, isAwaitingToken, userData, crispTokenId, prefilledMessage, setIsSupportModalOpen]) + }, [ + isSupportModalOpen, + isAwaitingToken, + userData, + crispTokenId, + prefilledMessage, + setIsSupportModalOpen, + clearSupportBadge, + ]) // drag-to-dismiss state const panelRef = useRef(null) diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index ad9000ab11..a89b6bf595 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,18 @@ const MobileNav: React.FC = ({ pathName }) => { { 'text-primary-1': pathName === '/support' } )} > - + + + {/* role="status" so the dot is announced. aria-label alone on a + bare span is ignored by assistive tech (generic role). */} + {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 ? (
-
+
) : (