Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5fa013f
refactor(fx): consume shared backend rate policy
0xkkonrad Aug 5, 2026
5f95fad
fix(fx): omit credentials from public rate reads
0xkkonrad Aug 5, 2026
5ade3d4
fix(fx): bound requests and avoid caching errors
0xkkonrad Aug 5, 2026
8b078cc
Merge remote-tracking branch 'origin/main' into fix/shared-fx-api
0xkkonrad Aug 5, 2026
312f595
fix(fx): consume shared pair contract
0xkkonrad Aug 5, 2026
dde18ca
Merge remote-tracking branch 'origin/main' into fix/shared-fx-api
0xkkonrad Aug 5, 2026
2e141e0
fix(fx): fail closed after refresh errors
0xkkonrad Aug 5, 2026
b56922f
fix(ci): consolidate UI workflow reliability
kushagrasarathe Aug 7, 2026
f25cc2b
fix(websocket): stop shipping raw frames to Sentry on a parse error
Hugo0 Aug 7, 2026
a1f2458
Merge pull request #2636 from peanutprotocol/fix/ci-workflow-consolid…
Hugo0 Aug 7, 2026
bc975e3
feat(support): unread badge on the Support nav icon
abalinda Aug 7, 2026
3b2adf2
test(support): give the markAllRead mock its real one-arg signature
abalinda Aug 7, 2026
ae6b564
fix(fx): survive a drifted device clock, and stop the 503 flood
Hugo0 Aug 7, 2026
263d1db
Merge origin/dev into fix/shared-fx-api
Hugo0 Aug 7, 2026
f02e6a6
test(support): pin visual parity of the extracted dot
abalinda Aug 7, 2026
5333974
review(coderabbit): label the parse-error log 'length', not 'bytes'
Hugo0 Aug 7, 2026
3b2c5d2
fix(support): clear the badge only when the chat is really shown
abalinda Aug 7, 2026
bc7b8ab
Merge pull request #2639 from peanutprotocol/feat/support-reply-badge
jjramirezn Aug 7, 2026
9674b2c
Merge pull request #2607 from peanutprotocol/fix/shared-fx-api
Hugo0 Aug 7, 2026
0505f3c
Merge pull request #2637 from peanutprotocol/fix/ws-parse-log-pii
Hugo0 Aug 7, 2026
7ab139c
Merge remote-tracking branch 'origin/main' into chore/backmerge-main-…
Hugo0 Aug 7, 2026
9a4dd66
Merge pull request #2641 from peanutprotocol/chore/backmerge-main-int…
Hugo0 Aug 7, 2026
3923659
fix(dev/journey): render the new 'balance' hold — api#1309 pairs a ne…
Hugo0 Aug 7, 2026
62976ab
Merge remote-tracking branch 'origin/main' into chore/backmerge-main-…
Hugo0 Aug 8, 2026
7415090
Merge pull request #2648 from peanutprotocol/chore/backmerge-main-int…
Hugo0 Aug 8, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/capgo-deploy-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ jobs:
--apikey "$CAPGO_API_KEY" \
--path ./out \
--auto-min-update-version \
--version-exists-ok \
--comment "$COMMENT"

- name: Deployment summary
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/capgo-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
--key-data-v2 "$CAPGO_PRIVATE_KEY" \
--path ./out \
--auto-min-update-version \
--version-exists-ok \
--comment "$COMMENT"

- name: Deployment summary
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/content-publish-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only)
# `gh pr diff` returns HTTP 406 after 300 files. The Files API
# is paginated, so large code PRs reach the same fail-closed
# exact-match decision instead of leaving a false-red check.
FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename')
echo "Changed files:"
echo "$FILES"
if [ "$FILES" = "src/content" ]; then
Expand Down
3 changes: 3 additions & 0 deletions src/app/(mobile-ui)/dev/journey/RulesLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default function RulesLegend({ rules, specError }: { rules: SpecRules | n
<Rule label="holdout">
<span className="text-xs font-bold">{Math.round(rules.holdoutFraction * 100)}% control</span>
</Rule>
<Rule label="balance gate">
<span className="text-xs font-bold">fund ≤ $0.10 · spend ≥ $1 (live chain read)</span>
</Rule>
<Rule label="send window">
<span className="text-xs font-bold">
{rules.sendWindowUtc.startHour}–{rules.sendWindowUtc.endHour}h UTC
Expand Down
2 changes: 2 additions & 0 deletions src/app/(mobile-ui)/dev/journey/UserInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export default function UserInspector() {
if (!result.due) return 'none due (graduated, not in audience, or up to date)'
if (result.due.skip === 'holdout') return `${result.due.type} — HELD (holdout control group)`
if (result.due.skip === 'governor') return `${result.due.type} — HELD (governor: too soon after last email)`
if (result.due.skip === 'balance')
return `${result.due.type} — HELD (balance gate: live balance contradicts the copy)`
return `${result.due.type} — due now${result.due.hasPendingRewards ? ' (rewards variant)' : ''}`
})()

Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/dev/journey/journeyTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export interface InspectDue {
userId: string
type: string
hasPendingRewards?: boolean
skip?: 'holdout' | 'governor'
skip?: 'holdout' | 'governor' | 'balance'
}

export interface InspectHistoryRow {
Expand Down
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
Loading
Loading