Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -254,6 +255,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => {

<SupportDrawer />

{/* Suspense is required: nuqs reads useSearchParams, which triggers
a client-side-rendering bailout without a boundary. */}
<Suspense fallback={null}>
<SupportDeepLink />
</Suspense>

<QRScannerOverlay />

<SecurityVerificationOverlay />
Expand Down
52 changes: 52 additions & 0 deletions src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<IndicatorDot />)
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(<IndicatorDot className="h-2 w-2 animate-pulsate" />)
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(<IndicatorDot className="animate-pulse" aria-label="highlight-indicator" />)
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(<IndicatorDot className="absolute -right-1 -top-1" />)
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(<IndicatorDot role="status" aria-label="New support reply" />)
expect(getByRole('status')).toHaveAttribute('aria-label', 'New support reply')
})
})
14 changes: 14 additions & 0 deletions src/components/Global/IndicatorDot/index.tsx
Original file line number Diff line number Diff line change
@@ -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'>) => (
<span className={twMerge('block h-2.5 w-2.5 rounded-full bg-primary-1', className)} {...props} />
)

export default IndicatorDot
25 changes: 25 additions & 0 deletions src/components/Global/SupportDeepLink/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand Down Expand Up @@ -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(<SupportDrawer />)
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(<SupportDrawer />)
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(<SupportDrawer />)
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 })
Expand Down
55 changes: 54 additions & 1 deletion src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
Expand Down
16 changes: 15 additions & 1 deletion src/components/Global/WalletNavigation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -76,6 +78,7 @@ const MobileNav: React.FC<MobileNavProps> = ({ pathName }) => {
const t = useTranslations('navigation')
const { setIsSupportModalOpen } = useModalsContext()
const { triggerHaptic } = useHaptic()
const hasUnreadSupport = useSupportUnread()

return (
<div className="z-1 grid h-20 grid-cols-3 border-t border-black bg-background md:hidden">
Expand Down Expand Up @@ -111,7 +114,18 @@ const MobileNav: React.FC<MobileNavProps> = ({ pathName }) => {
{ 'text-primary-1': pathName === '/support' }
)}
>
<NavIcon name="peanut-support" size={24} />
<span className="relative">
<NavIcon name="peanut-support" size={24} />
{/* role="status" so the dot is announced. aria-label alone on a
bare span is ignored by assistive tech (generic role). */}
{hasUnreadSupport && (
<IndicatorDot
className="absolute -right-1 -top-1"
role="status"
aria-label={t('supportUnread')}
/>
)}
</span>
<span className="mx-auto mt-1 block pl-1 text-center text-xs font-medium">{t('support')}</span>
</button>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -80,7 +81,7 @@ const CarouselCTA = ({
{/* Close button or pink dot indicator for perk claims */}
{isPerkClaim ? (
<div className={twMerge(CAROUSEL_CLOSE_BUTTON_POSITION, 'z-10')} aria-label={t('claimablePerk')}>
<div className="h-2.5 w-2.5 rounded-full bg-primary-1" />
<IndicatorDot />
</div>
) : (
<button
Expand Down
7 changes: 2 additions & 5 deletions src/components/Profile/components/ProfileMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import StatusBadge from '@/components/Global/Badges/StatusBadge'
import Card from '@/components/Global/Card'
import { type CardPosition } from '@/components/Global/Card/card.utils'
import { Icon, type IconName } from '@/components/Global/Icons/Icon'
import IndicatorDot from '@/components/Global/IndicatorDot'
import NavigationArrow from '@/components/Global/NavigationArrow'
import { Tooltip } from '@/components/Tooltip'
import Link from 'next/link'
Expand Down Expand Up @@ -55,11 +56,7 @@ const ProfileMenuItem: React.FC<ProfileMenuItemProps> = ({
)}
<div className="flex items-center gap-2">
<label className="text-base font-medium">{label}</label>
{highlight && (
<div className={'animate-pulse'} aria-label="highlight-indicator">
<div className="h-2.5 w-2.5 rounded-full bg-primary-1" />
</div>
)}
{highlight && <IndicatorDot className="animate-pulse" aria-label="highlight-indicator" />}
</div>
{badge && <StatusBadge status="custom" customText={badge} />}
{showTooltip && (
Expand Down
3 changes: 2 additions & 1 deletion src/components/TransactionDetails/TransactionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Card from '@/components/Global/Card'
import { type CardPosition } from '@/components/Global/Card/card.utils'
import { Icon, type IconName } from '@/components/Global/Icons/Icon'
import IndicatorDot from '@/components/Global/IndicatorDot'
import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionAvatarBadge'
import { getBankAccountCountryCode } from '@/constants/countryCurrencyMapping'
import { type TransactionDirection, type TransactionType } from '@/components/TransactionDetails/transaction-types'
Expand Down Expand Up @@ -237,7 +238,7 @@ const TransactionCard: React.FC<TransactionCardProps> = ({
<div className="flex flex-col">
{/* display formatted name (address or username) */}
<div className="flex flex-row items-center gap-2">
{isPending && <div className="h-2 w-2 animate-pulsate rounded-full bg-primary-1" />}
{isPending && <IndicatorDot className="h-2 w-2 animate-pulsate" />}
<div className="min-w-0 flex-1 truncate font-roboto text-[16px] font-medium">
<VerifiedUserLabel
username={transaction.userName}
Expand Down
Loading
Loading